detached 89a7478

feat(gui): rebuild homepage shell and blocked page

commit
89a747840b80b468d552ae83f917cadc728d51ca
author
bnema · · 2025-12-06 14:14 UTC
parent
0530f34
signature
unsigned
41 files · +8349 -2179
M assets/logo.svg +34 -16
@@ -1,30 +1,48 @@
1 <?xml version="1.0" encoding="UTF-8"?>1 <?xml version="1.0" encoding="UTF-8"?>
2 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">2 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
3 <defs>3 <defs>
4+ <!-- Background gradient - deeper dark -->
4 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">5 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
5- <stop offset="0%" stop-color="#111827"/>6+ <stop offset="0%" stop-color="#0a0a0b"/>
6- <stop offset="100%" stop-color="#1f2937"/>7+ <stop offset="100%" stop-color="#18181b"/>
7 </linearGradient>8 </linearGradient>
9+ <!-- Green accent gradient for bolt -->
10+ <linearGradient id="bolt" x1="0" y1="0" x2="0" y2="1">
11+ <stop offset="0%" stop-color="#4ade80"/>
12+ <stop offset="100%" stop-color="#22c55e"/>
13+ </linearGradient>
14+ <!-- Subtle inset shadow -->
8 <filter id="inset" x="-20%" y="-20%" width="140%" height="140%">15 <filter id="inset" x="-20%" y="-20%" width="140%" height="140%">
9 <feOffset dx="0" dy="1"/>16 <feOffset dx="0" dy="1"/>
10- <feGaussianBlur stdDeviation="0.6" result="blur"/>17+ <feGaussianBlur stdDeviation="0.8" result="blur"/>
11 <feComposite in2="SourceAlpha" operator="out"/>18 <feComposite in2="SourceAlpha" operator="out"/>
12- <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>19+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4 0"/>
13 <feBlend in="SourceGraphic" mode="normal"/>20 <feBlend in="SourceGraphic" mode="normal"/>
14 </filter>21 </filter>
22+ <!-- Glow for bolt -->
23+ <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
24+ <feGaussianBlur stdDeviation="2" result="blur"/>
25+ <feMerge>
26+ <feMergeNode in="blur"/>
27+ <feMergeNode in="SourceGraphic"/>
28+ </feMerge>
29+ </filter>
15 </defs>30 </defs>
31+
32+ <!-- Background square -->
16 <rect x="4" y="4" width="56" height="56" rx="0" ry="0" fill="url(#bg)" filter="url(#inset)"/>33 <rect x="4" y="4" width="56" height="56" rx="0" ry="0" fill="url(#bg)" filter="url(#inset)"/>
17- <rect x="4.5" y="4.5" width="55" height="55" rx="0" ry="0" fill="none" stroke="#374151" stroke-width="1"/>34+ <rect x="4.5" y="4.5" width="55" height="55" rx="0" ry="0" fill="none" stroke="#3f3f46" stroke-width="1"/>
18- <!-- D glyph (slightly larger and wider) -->35+
19- <path d="M20 16 L20 48 M20 16 H34 q12 0 12 16 t-12 16 H20"36+ <!-- D letterform - refined, sharper -->
37+ <path d="M18 14 L18 50 M18 14 H35 C48 14 48 32 48 32 C48 50 35 50 35 50 H18"
20 fill="none"38 fill="none"
21- stroke="#f3f4f6"39+ stroke="#fafafa"
22- stroke-width="4.5"40+ stroke-width="5"
23- stroke-linecap="round"41+ stroke-linecap="square"
24- stroke-linejoin="round"42+ stroke-linejoin="miter"/>
25- transform="translate(33,32) scale(1.12,1.10) translate(-33,-32)"/>43+
26- <!-- Speed bolt accent (significantly larger, centered) -->44+ <!-- Speed bolt - vibrant green, centered and prominent -->
27- <path d="M36 22 L32 34 h6 l-8 16 4-12 h-6 z"45+ <path d="M38 20 L33 33 h7 l-10 18 5-14 h-7 z"
28- fill="#60a5fa" opacity="0.95"46+ fill="url(#bolt)"
29- transform="translate(33,32) scale(1.8) translate(-33,-32) translate(0,-3)"/>47+ filter="url(#glow)"/>
30 </svg>48 </svg>
M gui/package-lock.json +253 -204 generated file: collapsed
M gui/package.json +6 -1
@@ -4,13 +4,15 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
7- "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage && npm run build:main-world",7+ "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage && npm run build:blocked && npm run build:main-world",
8 "build:gui": "vite build --config vite.config.gui.ts",8 "build:gui": "vite build --config vite.config.gui.ts",
9 "build:homepage": "vite build --config vite.config.homepage.ts",9 "build:homepage": "vite build --config vite.config.homepage.ts",
10+ "build:blocked": "vite build --config vite.config.blocked.ts",
10 "build:color-scheme": "vite build --config vite.config.color-scheme.ts",11 "build:color-scheme": "vite build --config vite.config.color-scheme.ts",
11 "build:main-world": "vite build --config vite.config.main-world.ts",12 "build:main-world": "vite build --config vite.config.main-world.ts",
12 "dev": "npm run build:gui -- --watch",13 "dev": "npm run build:gui -- --watch",
13 "dev:homepage": "npm run build:homepage -- --watch",14 "dev:homepage": "npm run build:homepage -- --watch",
15+ "dev:blocked": "npm run build:blocked -- --watch",
14 "dev:color-scheme": "npm run build:color-scheme -- --watch",16 "dev:color-scheme": "npm run build:color-scheme -- --watch",
15 "dev:main-world": "npm run build:main-world -- --watch",17 "dev:main-world": "npm run build:main-world -- --watch",
16 "check": "svelte-check",18 "check": "svelte-check",
@@ -34,5 +36,8 @@
34 "typescript": "^5.3.0",36 "typescript": "^5.3.0",
35 "typescript-eslint": "^8.43.0",37 "typescript-eslint": "^8.43.0",
36 "vite": "^5.0.0"38 "vite": "^5.0.0"
39+ },
40+ "dependencies": {
41+ "@lucide/svelte": "^0.556.0"
37 }42 }
38 }43 }
M gui/src/components/omnibox/OmniboxInput.svelte +3 -7
@@ -100,11 +100,7 @@
100 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);100 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
101 let inputPadding = $state(DEFAULT_PADDING.join(' '));101 let inputPadding = $state(DEFAULT_PADDING.join(' '));
102 let baseLeftPadding = $state(DEFAULT_PADDING[3]);102 let baseLeftPadding = $state(DEFAULT_PADDING[3]);
103- let inputFontSize = $state(responsiveStyles?.fontSize || '16px');103+ let inputFontSize = $derived(responsiveStyles?.fontSize || '16px');
104-
105- $effect(() => {
106- inputFontSize = responsiveStyles?.fontSize || '16px';
107- });
108 104
109 $effect(() => {105 $effect(() => {
110 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);106 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
@@ -367,9 +363,9 @@
367 363
368 if (!item || !item.url) return;364 if (!item || !item.url) return;
369 365
370- // Extract title and favicon366+ // Extract title and favicon with proper type narrowing
371 const title = 'title' in item ? item.title : '';367 const title = 'title' in item ? item.title : '';
372- const faviconURL = item.favicon_url || item.favicon || '';368+ const faviconURL = 'favicon_url' in item ? item.favicon_url : ('favicon' in item ? item.favicon : '') ?? '';
373 369
374 // Toggle the favorite status370 // Toggle the favorite status
375 omniboxBridge.toggleFavorite(item.url, title, faviconURL);371 omniboxBridge.toggleFavorite(item.url, title, faviconURL);
M gui/src/components/omnibox/OmniboxSuggestions.svelte +4 -4
@@ -27,9 +27,9 @@
27 // In favorites view with input, filter locally27 // In favorites view with input, filter locally
28 if (viewMode === 'favorites' && inputValue && inputValue.trim()) {28 if (viewMode === 'favorites' && inputValue && inputValue.trim()) {
29 const query = inputValue.toLowerCase().trim();29 const query = inputValue.toLowerCase().trim();
30- return list.filter(fav =>30+ return list.filter(item =>
31- fav.url.toLowerCase().includes(query) ||31+ item.url.toLowerCase().includes(query) ||
32- (fav.title && fav.title.toLowerCase().includes(query))32+ ('title' in item && item.title && item.title.toLowerCase().includes(query))
33 );33 );
34 }34 }
35 35
@@ -150,7 +150,7 @@
150 {#each currentList as item, index (item.url)}150 {#each currentList as item, index (item.url)}
151 {@const { domain, path } = parseUrl(item.url)}151 {@const { domain, path } = parseUrl(item.url)}
152 {@const isSelected = index === selectedIndex}152 {@const isSelected = index === selectedIndex}
153- {@const favicon = item.favicon || item.favicon_url || ''}153+ {@const favicon = ('favicon' in item ? item.favicon : '') || ('favicon_url' in item ? item.favicon_url : '') || ''}
154 {@const isFav = viewMode === 'history' && isFavorited(item.url)}154 {@const isFav = viewMode === 'history' && isFavorited(item.url)}
155 {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null}155 {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null}
156 156
M gui/src/components/omnibox/messaging.ts +6 -0
@@ -183,6 +183,12 @@ export class OmniboxBridge implements OmniboxMessageBridge {
183 toggleFavorite(url: string, title: string, faviconURL: string): void {183 toggleFavorite(url: string, title: string, faviconURL: string): void {
184 console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL });184 console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL });
185 this.postMessage({ type: "toggle_favorite", url, title, faviconURL });185 this.postMessage({ type: "toggle_favorite", url, title, faviconURL });
186+
187+ // Notify other parts of the app that favorites changed
188+ // Homepage will listen for this and refresh its favorites list
189+ setTimeout(() => {
190+ document.dispatchEvent(new CustomEvent("dumber:favorites-changed"));
191+ }, 100); // Small delay to let backend complete
186 }192 }
187 193
188 /**194 /**
M gui/src/components/omnibox/types.ts +2 -1
@@ -23,12 +23,13 @@ export interface SearchShortcut {
23 }23 }
24 24
25 export interface OmniboxMessage {25 export interface OmniboxMessage {
26- type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query";26+ type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query" | "keyboard_blocking";
27 url?: string;27 url?: string;
28 q?: string;28 q?: string;
29 limit?: number;29 limit?: number;
30 title?: string;30 title?: string;
31 faviconURL?: string;31 faviconURL?: string;
32+ action?: "enable" | "disable";
32 }33 }
33 34
34 export interface FindMatch {35 export interface FindMatch {
M gui/src/components/toast/ToastContainer.svelte +2 -0
@@ -49,6 +49,8 @@
49 zoomDebounceTimer = undefined;49 zoomDebounceTimer = undefined;
50 }50 }
51 }51 }
52+ // Export for external use
53+ window.__dumber_clearToasts = clearAllToasts;
52 54
53 function showZoomToast(zoomLevel: number) {55 function showZoomToast(zoomLevel: number) {
54 console.log('[dumber] showZoomToast called with level:', zoomLevel);56 console.log('[dumber] showZoomToast called with level:', zoomLevel);
M gui/src/injected/bootstrap.ts +3 -2
@@ -16,7 +16,7 @@ import type { Suggestion } from "../components/omnibox/types";
16 declare global {16 declare global {
17 interface Window {17 interface Window {
18 __dumber_gui?: DumberGUI;18 __dumber_gui?: DumberGUI;
19- __dumber_gui_ready?: boo19+ __dumber_gui_ready?: boolean;
20 __dumber_gui_ready_for?: Document | null;20 __dumber_gui_ready_for?: Document | null;
21 __dumber_showToast?: (21 __dumber_showToast?: (
22 message: string,22 message: string,
@@ -61,13 +61,14 @@ function whenDOMReady(callback: () => void) {
61 }61 }
62 }62 }
63 63
64-function whenPageLoaded(callback: () => void) {64+function _whenPageLoaded(callback: () => void) {
65 if (document.readyState === "complete") {65 if (document.readyState === "complete") {
66 callback();66 callback();
67 } else {67 } else {
68 window.addEventListener("load", callback, { once: true });68 window.addEventListener("load", callback, { once: true });
69 }69 }
70 }70 }
71+void _whenPageLoaded; // Reserved for future use
71 72
72 function normalizeWebviewId(value: unknown): number | null {73 function normalizeWebviewId(value: unknown): number | null {
73 if (typeof value === "number" && Number.isFinite(value)) {74 if (typeof value === "number" && Number.isFinite(value)) {
A gui/src/pages/BlockedPage.svelte +330 -0
@@ -0,0 +1,330 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+
4+ let blockedUrl = $state('');
5+ let blockedDomain = $state('');
6+ let reason = $state('Content blocked by filter');
7+ let isLoading = $state(false);
8+ let error = $state('');
9+
10+ onMount(() => {
11+ // Parse URL parameters
12+ const params = new URLSearchParams(window.location.search);
13+ blockedUrl = params.get('url') || '';
14+ reason = params.get('reason') || 'Content blocked by filter';
15+
16+ // Extract domain from URL
17+ try {
18+ if (blockedUrl) {
19+ const url = new URL(blockedUrl);
20+ blockedDomain = url.hostname;
21+ }
22+ } catch {
23+ blockedDomain = blockedUrl;
24+ }
25+ });
26+
27+ function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
28+ return new Promise((resolve, reject) => {
29+ const requestId = `blocked_${Date.now()}_${Math.random().toString(36).slice(2)}`;
30+
31+ const handler = (event: MessageEvent) => {
32+ if (event.data?.requestId === requestId) {
33+ window.removeEventListener('message', handler);
34+ if (event.data.success) {
35+ resolve(event.data.data);
36+ } else {
37+ reject(new Error(event.data.error || 'Unknown error'));
38+ }
39+ }
40+ };
41+
42+ window.addEventListener('message', handler);
43+
44+ // Send to WebKit message handler
45+ if (window.webkit?.messageHandlers?.dumber) {
46+ window.webkit.messageHandlers.dumber.postMessage(
47+ JSON.stringify({ type, requestId, payload })
48+ );
49+ } else {
50+ reject(new Error('WebKit message handler not available'));
51+ }
52+
53+ // Timeout after 10 seconds
54+ setTimeout(() => {
55+ window.removeEventListener('message', handler);
56+ reject(new Error('Request timeout'));
57+ }, 10000);
58+ });
59+ }
60+
61+ async function addToWhitelist() {
62+ if (!blockedDomain) return;
63+
64+ isLoading = true;
65+ error = '';
66+
67+ try {
68+ await sendMessage('addToWhitelist', { domain: blockedDomain });
69+ // Reload the original URL after whitelisting
70+ window.location.href = blockedUrl;
71+ } catch (e) {
72+ error = e instanceof Error ? e.message : 'Failed to add to whitelist';
73+ isLoading = false;
74+ }
75+ }
76+
77+ async function bypassOnce() {
78+ if (!blockedUrl) return;
79+
80+ isLoading = true;
81+ error = '';
82+
83+ try {
84+ await sendMessage('bypassOnce', { url: blockedUrl });
85+ // Reload the original URL after bypass
86+ window.location.href = blockedUrl;
87+ } catch (e) {
88+ error = e instanceof Error ? e.message : 'Failed to bypass';
89+ isLoading = false;
90+ }
91+ }
92+
93+ function goBack() {
94+ window.history.back();
95+ }
96+</script>
97+
98+<svelte:head>
99+ <title>Page Blocked</title>
100+ {@html `<style>
101+ html, body {
102+ background: var(--dynamic-bg, #0a0a0a);
103+ margin: 0;
104+ padding: 0;
105+ min-height: 100vh;
106+ }
107+ </style>`}
108+</svelte:head>
109+
110+<div class="blocked-container">
111+ <div class="blocked-card">
112+ <div class="icon-container">
113+ <svg class="shield-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
114+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
115+ <path d="M9 12l2 2 4-4"/>
116+ </svg>
117+ </div>
118+
119+ <h1 class="title">Page Blocked</h1>
120+
121+ <p class="reason">{reason}</p>
122+
123+ {#if blockedUrl}
124+ <div class="url-display">
125+ <span class="url-label">Blocked URL:</span>
126+ <code class="url-value">{blockedUrl}</code>
127+ </div>
128+ {/if}
129+
130+ {#if error}
131+ <div class="error-message">{error}</div>
132+ {/if}
133+
134+ <div class="actions">
135+ <button
136+ class="btn btn-primary"
137+ onclick={addToWhitelist}
138+ disabled={isLoading || !blockedDomain}
139+ >
140+ {#if isLoading}
141+ <span class="loading-spinner"></span>
142+ {:else}
143+ Add to Whitelist
144+ {/if}
145+ </button>
146+
147+ <button
148+ class="btn btn-secondary"
149+ onclick={bypassOnce}
150+ disabled={isLoading || !blockedUrl}
151+ >
152+ Go Anyway (Once)
153+ </button>
154+
155+ <button
156+ class="btn btn-ghost"
157+ onclick={goBack}
158+ disabled={isLoading}
159+ >
160+ Go Back
161+ </button>
162+ </div>
163+
164+ {#if blockedDomain}
165+ <p class="domain-note">
166+ Adding <strong>{blockedDomain}</strong> to whitelist will disable content filtering for this site.
167+ </p>
168+ {/if}
169+ </div>
170+</div>
171+
172+<style>
173+ .blocked-container {
174+ min-height: 100vh;
175+ display: flex;
176+ align-items: center;
177+ justify-content: center;
178+ padding: 2rem;
179+ background: var(--dynamic-bg, #0a0a0a);
180+ color: var(--dynamic-text, #e5e5e5);
181+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
182+ }
183+
184+ .blocked-card {
185+ max-width: 480px;
186+ width: 100%;
187+ padding: 2.5rem 2rem;
188+ background: var(--dynamic-surface, #141414);
189+ border: 1px solid var(--dynamic-border, #262626);
190+ text-align: center;
191+ }
192+
193+ .icon-container {
194+ margin-bottom: 1.5rem;
195+ }
196+
197+ .shield-icon {
198+ width: 64px;
199+ height: 64px;
200+ color: var(--dynamic-accent, #3b82f6);
201+ opacity: 0.9;
202+ }
203+
204+ .title {
205+ font-size: 1.5rem;
206+ font-weight: 600;
207+ margin: 0 0 0.75rem 0;
208+ color: var(--dynamic-text, #e5e5e5);
209+ }
210+
211+ .reason {
212+ font-size: 0.9375rem;
213+ color: var(--dynamic-muted, #737373);
214+ margin: 0 0 1.5rem 0;
215+ }
216+
217+ .url-display {
218+ background: var(--dynamic-bg, #0a0a0a);
219+ border: 1px solid var(--dynamic-border, #262626);
220+ padding: 0.75rem 1rem;
221+ margin-bottom: 1.5rem;
222+ text-align: left;
223+ }
224+
225+ .url-label {
226+ display: block;
227+ font-size: 0.75rem;
228+ color: var(--dynamic-muted, #737373);
229+ margin-bottom: 0.25rem;
230+ text-transform: uppercase;
231+ letter-spacing: 0.05em;
232+ }
233+
234+ .url-value {
235+ font-family: 'JetBrains Mono NF', monospace;
236+ font-size: 0.8125rem;
237+ color: var(--dynamic-text, #e5e5e5);
238+ word-break: break-all;
239+ display: block;
240+ }
241+
242+ .error-message {
243+ background: rgba(239, 68, 68, 0.1);
244+ border: 1px solid rgba(239, 68, 68, 0.3);
245+ color: #ef4444;
246+ padding: 0.75rem 1rem;
247+ margin-bottom: 1.5rem;
248+ font-size: 0.875rem;
249+ }
250+
251+ .actions {
252+ display: flex;
253+ flex-direction: column;
254+ gap: 0.75rem;
255+ margin-bottom: 1.5rem;
256+ }
257+
258+ .btn {
259+ padding: 0.75rem 1.5rem;
260+ font-size: 0.875rem;
261+ font-weight: 500;
262+ border: 1px solid transparent;
263+ cursor: pointer;
264+ transition: all 150ms ease;
265+ display: inline-flex;
266+ align-items: center;
267+ justify-content: center;
268+ gap: 0.5rem;
269+ }
270+
271+ .btn:disabled {
272+ opacity: 0.5;
273+ cursor: not-allowed;
274+ }
275+
276+ .btn-primary {
277+ background: var(--dynamic-accent, #3b82f6);
278+ color: white;
279+ border-color: var(--dynamic-accent, #3b82f6);
280+ }
281+
282+ .btn-primary:hover:not(:disabled) {
283+ filter: brightness(1.1);
284+ }
285+
286+ .btn-secondary {
287+ background: transparent;
288+ color: var(--dynamic-text, #e5e5e5);
289+ border-color: var(--dynamic-border, #262626);
290+ }
291+
292+ .btn-secondary:hover:not(:disabled) {
293+ background: var(--dynamic-surface-variant, #1a1a1a);
294+ border-color: var(--dynamic-muted, #737373);
295+ }
296+
297+ .btn-ghost {
298+ background: transparent;
299+ color: var(--dynamic-muted, #737373);
300+ border-color: transparent;
301+ }
302+
303+ .btn-ghost:hover:not(:disabled) {
304+ color: var(--dynamic-text, #e5e5e5);
305+ }
306+
307+ .loading-spinner {
308+ width: 1rem;
309+ height: 1rem;
310+ border: 2px solid transparent;
311+ border-top-color: currentColor;
312+ border-radius: 50%;
313+ animation: spin 0.6s linear infinite;
314+ }
315+
316+ @keyframes spin {
317+ to { transform: rotate(360deg); }
318+ }
319+
320+ .domain-note {
321+ font-size: 0.75rem;
322+ color: var(--dynamic-muted, #737373);
323+ margin: 0;
324+ }
325+
326+ .domain-note strong {
327+ color: var(--dynamic-text, #e5e5e5);
328+ font-weight: 500;
329+ }
330+</style>
M gui/src/pages/Homepage.svelte +12 -1936
@@ -1,1939 +1,15 @@
1 <script lang="ts">1 <script lang="ts">
2- import { onMount } from 'svelte';2+ import { HomepageShell } from './homepage/shell';
3- import Footer from '$components/Footer.svelte';3+ import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
4- // Icons disabled for investigation: removed @lucide/svelte imports4+ import { homepageState } from './homepage/state.svelte';
5-
6- interface HistoryItem {
7- id: number;
8- url: string;
9- title: string;
10- favicon_url?: string;
11- visit_count: number;
12- last_visited: string;
13- created_at: string;
14- }
15-
16- interface Shortcut {
17- key: string;
18- description: string;
19- }
20-
21- interface HistoryStats {
22- total_entries: number;
23- recent_count: number;
24- oldest_entry?: string;
25- newest_entry?: string;
26- }
27-
28- interface SearchShortcut {
29- url: string;
30- description: string;
31- }
32-
33- let historyItems = $state<HistoryItem[]>([]);
34- let topVisited = $state<HistoryItem[]>([]);
35- let shortcuts = $state<Shortcut[]>([]);
36- let stats = $state<HistoryStats | null>(null);
37- let historyLoading = $state(true);
38- let topVisitedLoading = $state(true);
39- let shortcutsLoading = $state(true);
40- let statsLoading = $state(true);
41- let searchShortcuts = $state<Record<string, SearchShortcut>>({});
42- let searchShortcutsLoading = $state(true);
43- let deletingIds = $state<number[]>([]);
44- let historyOffset = $state(0);
45- let hasMoreHistory = $state(true);
46- let loadingMoreHistory = $state(false);
47- const MIN_VISIT_COUNT_FOR_QUICK_ACCESS = 2;
48- type ThemeMode = "light" | "dark";
49- const THEME_STORAGE_KEY = "dumber.theme";
50- let themeMode = $state<ThemeMode>("dark");
51- let themeObserver: MutationObserver | null = null;
52- const INITIAL_HISTORY_DISPLAY = 15;
53- let showAllHistory = $state(false);
54- const PINNED_SITES_STORAGE_KEY = "dumber.pinnedSites";
55- let pinnedSites = $state<Set<string>>(new Set());
56- let displayedHistoryItems = $derived(
57- showAllHistory ? historyItems : historyItems.slice(0, INITIAL_HISTORY_DISPLAY)
58- );
59-
60- // Function for checking if an item is being deleted
61- const isDeleting = (id: number) => deletingIds.includes(id);
62-
63- // Pinned sites management
64- const loadPinnedSites = () => {
65- try {
66- const stored = localStorage.getItem(PINNED_SITES_STORAGE_KEY);
67- if (stored) {
68- const urls = JSON.parse(stored) as string[];
69- pinnedSites = new Set(urls);
70- }
71- } catch (error) {
72- console.warn("[homepage] Failed to load pinned sites", error);
73- pinnedSites = new Set();
74- }
75- };
76-
77- const savePinnedSites = () => {
78- try {
79- const urls = Array.from(pinnedSites);
80- localStorage.setItem(PINNED_SITES_STORAGE_KEY, JSON.stringify(urls));
81- } catch (error) {
82- console.warn("[homepage] Failed to save pinned sites", error);
83- }
84- };
85-
86- const isPinned = (url: string): boolean => pinnedSites.has(url);
87-
88- const togglePin = (url: string) => {
89- if (pinnedSites.has(url)) {
90- pinnedSites.delete(url);
91- } else {
92- pinnedSites.add(url);
93- }
94- pinnedSites = new Set(pinnedSites); // Trigger reactivity
95- savePinnedSites();
96- };
97-
98- // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
99- const initializeShortcuts = () => {
100- shortcuts = [
101- // Browser shortcuts
102- {
103- key: 'Ctrl+L',
104- description: 'Focus address bar'
105- },
106- {
107- key: 'Ctrl+F',
108- description: 'Find in page'
109- },
110- {
111- key: 'Ctrl+Shift+C',
112- description: 'Copy URL'
113- },
114- {
115- key: 'Ctrl+R / F5',
116- description: 'Reload page'
117- },
118- {
119- key: 'Ctrl+Shift+R',
120- description: 'Hard reload'
121- },
122- {
123- key: 'F12',
124- description: 'DevTools'
125- },
126- {
127- key: 'Ctrl+← / Ctrl+→',
128- description: 'Navigate back/forward'
129- },
130- {
131- key: 'Ctrl+0',
132- description: 'Reset zoom'
133- },
134- {
135- key: 'Ctrl++ / Ctrl+-',
136- description: 'Zoom in/out'
137- },
138- // Workspace pane mode
139- {
140- key: 'Ctrl+P',
141- description: 'Enter pane mode'
142- },
143- {
144- key: '→ / R (pane mode)',
145- description: 'Split pane right'
146- },
147- {
148- key: '← / L (pane mode)',
149- description: 'Split pane left'
150- },
151- {
152- key: '↑ / U (pane mode)',
153- description: 'Split pane up'
154- },
155- {
156- key: '↓ / D (pane mode)',
157- description: 'Split pane down'
158- },
159- {
160- key: 'X (pane mode)',
161- description: 'Close current pane'
162- },
163- {
164- key: 'Enter (pane mode)',
165- description: 'Confirm action'
166- },
167- {
168- key: 'Escape (pane mode)',
169- description: 'Exit pane mode'
170- },
171- // Workspace navigation
172- {
173- key: 'Alt+Arrow Keys',
174- description: 'Navigate between panes'
175- }
176- ];
177- shortcutsLoading = false;
178- };
179-
180- // Setup message bridge callbacks (once)
181- let callbacksInitialized = false;
182- const pendingRequests = new Map();
183-
184- const initializeCallbacks = () => {
185- if (callbacksInitialized) return;
186-
187- (window as any).__dumber_history_recent = (data: any[], requestId?: string) => {
188- const request = pendingRequests.get(requestId || 'default');
189- if (request) {
190- request.resolve(data);
191- pendingRequests.delete(requestId || 'default');
192- }
193- };
194-
195- (window as any).__dumber_history_error = (error: string, requestId?: string) => {
196- const request = pendingRequests.get(requestId || 'default');
197- if (request) {
198- request.reject(new Error(error));
199- pendingRequests.delete(requestId || 'default');
200- }
201- };
202-
203- // Omnibox suggestions callback - forward to injected script via DOM event bridge
204- let _pendingSuggestions: any[] | null = null;
205- (window as any).__dumber_omnibox_suggestions = (suggestions: any[]) => {
206- try {
207- console.log('🎯 [HOMEPAGE] Received omnibox suggestions:', suggestions?.length || 0, 'items');
208- // Dispatch an event that injected-world listener can consume
209- const evt = new CustomEvent('dumber:omnibox-suggestions', {
210- detail: { suggestions }
211- });
212- document.dispatchEvent(evt);
213- _pendingSuggestions = null; // clear cache on successful dispatch
214- } catch (err) {
215- console.warn('⚠️ [HOMEPAGE] Failed to dispatch suggestions event, caching:', err);
216- _pendingSuggestions = suggestions;
217- }
218- };
219-
220- // If omnibox gets ready later, re-send any pending suggestions
221- document.addEventListener('dumber:omnibox-ready', () => {
222- if (_pendingSuggestions && Array.isArray(_pendingSuggestions)) {
223- try {
224- const evt = new CustomEvent('dumber:omnibox-suggestions', {
225- detail: { suggestions: _pendingSuggestions }
226- });
227- document.dispatchEvent(evt);
228- _pendingSuggestions = null;
229- console.log('🔄 [HOMEPAGE] Re-dispatched pending suggestions after omnibox-ready');
230- } catch (e) {
231- console.warn('⚠️ [HOMEPAGE] Failed to re-dispatch pending suggestions:', e);
232- }
233- }
234- });
235-
236- callbacksInitialized = true;
237- };
238-
239- const sendHistoryRequest = (type: string, params: any): Promise<any[]> => {
240- initializeCallbacks();
241-
242- const requestId = `${type}_${Date.now()}_${Math.random()}`;
243-
244- return new Promise((resolve, reject) => {
245- pendingRequests.set(requestId, { resolve, reject });
246-
247- // Timeout after 10 seconds
248- setTimeout(() => {
249- if (pendingRequests.has(requestId)) {
250- pendingRequests.delete(requestId);
251- reject(new Error('Request timeout'));
252- }
253- }, 10000);
254-
255- try {
256- const bridge = (window as any).webkit?.messageHandlers?.dumber;
257- if (bridge && typeof bridge.postMessage === 'function') {
258- bridge.postMessage(JSON.stringify({
259- ...params,
260- type,
261- requestId
262- }));
263- } else {
264- reject(new Error('WebKit message handler not available'));
265- }
266- } catch (error) {
267- pendingRequests.delete(requestId);
268- reject(error);
269- }
270- });
271- };
272-
273- // Fetch recent history via message bridge
274- const fetchHistory = async () => {
275- try {
276- const data = await sendHistoryRequest('history_recent', {
277- limit: 20,
278- offset: 0
279- });
280-
281- historyItems = Array.isArray(data) ? data : [];
282- historyOffset = historyItems.length;
283- hasMoreHistory = data.length === 20;
284- } catch (error) {
285- console.error('Error fetching history:', error);
286- historyItems = [];
287- hasMoreHistory = false;
288- } finally {
289- historyLoading = false;
290- }
291- };
292-
293- // Load more history items for infinite scroll
294- const loadMoreHistory = async () => {
295- if (loadingMoreHistory || !hasMoreHistory) return;
296-
297- loadingMoreHistory = true;
298- try {
299- const data = await sendHistoryRequest('history_recent', {
300- limit: 20,
301- offset: historyOffset
302- });
303-
304- if (Array.isArray(data) && data.length > 0) {
305- historyItems = [...historyItems, ...data];
306- historyOffset += data.length;
307- hasMoreHistory = data.length === 20; // If we got full batch, there might be more
308- } else {
309- hasMoreHistory = false;
310- }
311- } catch (error) {
312- console.error('Error fetching more history:', error);
313- hasMoreHistory = false;
314- } finally {
315- loadingMoreHistory = false;
316- }
317- };
318-
319- // Navigate to a URL when history item is clicked
320- const navigateTo = (url: string) => {
321- window.location.href = url;
322- };
323-
324- // Delete a history entry
325- const deleteHistoryEntry = async (id: number, event: Event) => {
326- event.stopPropagation(); // Prevent navigation
327-
328- // Add to deleting array for animation
329- if (!deletingIds.includes(id)) {
330- deletingIds = [...deletingIds, id];
331- }
332-
333- try {
334- // Set up response handlers
335- (window as any).__dumber_history_deleted = async () => {
336- // Wait a bit for the fade animation to complete
337- await new Promise(resolve => setTimeout(resolve, 200));
338-
339- // Reset pagination and refresh history after successful deletion
340- historyOffset = 0;
341- hasMoreHistory = true;
342- await fetchHistory();
343- await fetchStats();
344- await fetchTopVisited();
345-
346- // Remove from deleting array after refresh
347- deletingIds = deletingIds.filter(i => i !== id);
348- };
349-
350- (window as any).__dumber_history_error = (error: string) => {
351- console.error('Error deleting history entry:', error);
352- // Remove from deleting array if failed
353- deletingIds = deletingIds.filter(i => i !== id);
354- };
355-
356- // Send message to Go backend
357- const bridge = (window as any).webkit?.messageHandlers?.dumber;
358- if (bridge && typeof bridge.postMessage === 'function') {
359- bridge.postMessage(JSON.stringify({
360- type: 'history_delete',
361- historyId: id.toString()
362- }));
363- } else {
364- throw new Error('WebKit message handler not available');
365- }
366- } catch (error) {
367- console.error('Error sending delete request:', error);
368- // Remove from deleting array if failed
369- deletingIds = deletingIds.filter(i => i !== id);
370- }
371- };
372-
373- // Get domain from URL
374- const getDomain = (url: string): string => {
375- try {
376- return new URL(url).hostname;
377- } catch {
378- return url;
379- }
380- };
381-
382- const getDisplayTitle = (item: HistoryItem): string => {
383- if (!item) return '';
384- const trimmed = item.title?.trim();
385- if (trimmed) return trimmed;
386- const domain = getDomain(item.url);
387- return domain || item.url;
388- };
389-
390- const highlightDomainInUrl = (url: string): string => {
391- try {
392- const urlObj = new URL(url);
393- const domain = urlObj.hostname;
394- const domainStart = url.indexOf(domain);
395-
396- if (domainStart === -1) return url;
397-
398- const beforeDomain = url.slice(0, domainStart);
399- const afterDomain = url.slice(domainStart + domain.length);
400-
401- return `${beforeDomain}<span class="history-url-domain">${domain}</span>${afterDomain}`;
402- } catch {
403- return url;
404- }
405- };
406-
407- // Format relative time
408- const formatTime = (timestamp: string): string => {
409- try {
410- const date = new Date(timestamp);
411- const now = new Date();
412- const diff = now.getTime() - date.getTime();
413-
414- const minutes = Math.floor(diff / (1000 * 60));
415- const hours = Math.floor(diff / (1000 * 60 * 60));
416- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
417-
418- if (minutes < 1) return 'Just now';
419- if (minutes < 60) return `${minutes}m ago`;
420- if (hours < 24) return `${hours}h ago`;
421- if (days < 7) return `${days}d ago`;
422-
423- return date.toLocaleDateString();
424- } catch {
425- return '';
426- }
427- };
428-
429- const formatCalendarDate = (timestamp?: string): string => {
430- if (!timestamp) return '—';
431- try {
432- const date = new Date(timestamp);
433- return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
434- } catch {
435- return '—';
436- }
437- };
438-
439- const formatVisitLabel = (count: number): string => {
440- if (!count || count <= 0) return 'Open';
441- if (count >= 1000) {
442- const value = count / 1000;
443- const formatted = value >= 10 ? Math.round(value).toString() : value.toFixed(1).replace(/\.0$/, '');
444- return `${formatted}k visits`;
445- }
446- return `${count} ${count === 1 ? 'visit' : 'visits'}`;
447- };
448-
449- const buildQuickAccess = (): HistoryItem[] => {
450- const picks: HistoryItem[] = [];
451- const seen = new Set<string>();
452-
453- const pushItem = (item: HistoryItem | null | undefined) => {
454- if (!item) return;
455- if (seen.has(item.url)) return;
456- seen.add(item.url);
457- picks.push(item);
458- };
459-
460- // First, add pinned sites (no visit count requirement for pinned)
461- for (const item of topVisited ?? []) {
462- if (pinnedSites.has(item.url)) {
463- pushItem(item);
464- }
465- }
466-
467- for (const item of historyItems ?? []) {
468- if (pinnedSites.has(item.url)) {
469- pushItem(item);
470- }
471- }
472-
473- // Then add most visited sites (non-pinned, with minimum visit count)
474- for (const item of topVisited ?? []) {
475- if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
476- pushItem(item);
477- }
478- }
479-
480- // Finally add recent history (non-pinned, with minimum visit count)
481- for (const item of historyItems ?? []) {
482- if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
483- pushItem(item);
484- }
485- }
486-
487- return picks;
488- };
489-
490- let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));
491- let quickAccessLoading = $derived(topVisitedLoading || historyLoading);
492- interface LatestVisitMeta {
493- entry: HistoryItem;
494- relative: string;
495- absolute: string;
496- domain: string;
497- title: string;
498- }
499-
500- const latestVisitInfo = $derived<LatestVisitMeta | null>((() => {
501- if (!historyItems.length) return null;
502-
503- let latest: HistoryItem | null = null;
504- let latestEpoch = Number.NEGATIVE_INFINITY;
505-
506- for (const entry of historyItems) {
507- const epoch = Number(new Date(entry.last_visited));
508- if (!Number.isFinite(epoch)) continue;
509- if (epoch > latestEpoch) {
510- latest = entry;
511- latestEpoch = epoch;
512- }
513- }
514-
515- if (!latest) return null;
516-
517- return {
518- entry: latest,
519- relative: formatTime(latest.last_visited),
520- absolute: formatCalendarDate(latest.last_visited),
521- domain: getDomain(latest.url),
522- title: getDisplayTitle(latest)
523- };
524- })());
525-
526- const persistTheme = (mode: ThemeMode) => {
527- try {
528- localStorage.setItem(THEME_STORAGE_KEY, mode);
529- } catch (error) {
530- console.warn("[homepage] Failed to persist theme preference", error);
531- }
532- };
533-
534- const applyTheme = (mode: ThemeMode) => {
535- themeMode = mode;
536-
537- const manager = (window as any).__dumber_color_scheme_manager as
538- | { setUserPreference?: (theme: ThemeMode) => void }
539- | undefined;
540-
541- if (manager?.setUserPreference) {
542- manager.setUserPreference(mode);
543- return;
544- }
545-
546- if (window.__dumber_setTheme) {
547- window.__dumber_setTheme(mode);
548- persistTheme(mode);
549- return;
550- }
551-
552- switch (mode) {
553- case "light":
554- document.documentElement.classList.add("light");
555- document.documentElement.classList.remove("dark");
556- break;
557- case "dark":
558- document.documentElement.classList.remove("light");
559- document.documentElement.classList.add("dark");
560- break;
561- }
562- persistTheme(mode);
563- };
564-
565- const toggleTheme = () => {
566- const nextMode = themeMode === "dark" ? "light" : "dark";
567- applyTheme(nextMode);
568- };
569-
570- // Fetch history stats via message bridge
571- const fetchStats = async () => {
572- try {
573- // Set up response handler
574- (window as any).__dumber_history_stats = (data: any) => {
575- stats = data;
576- statsLoading = false;
577- };
578-
579- (window as any).__dumber_history_error = (error: string) => {
580- console.error('Error fetching stats:', error);
581- stats = null;
582- statsLoading = false;
583- };
584-
585- // Send message to Go backend
586- const bridge = (window as any).webkit?.messageHandlers?.dumber;
587- if (bridge && typeof bridge.postMessage === 'function') {
588- bridge.postMessage(JSON.stringify({
589- type: 'history_stats'
590- }));
591- } else {
592- throw new Error('WebKit message handler not available');
593- }
594- } catch (error) {
595- console.error('Error sending stats request:', error);
596- stats = null;
597- statsLoading = false;
598- }
599- };
600-
601- // Fetch top visited sites via message bridge
602- const fetchTopVisited = async () => {
603- try {
604- // Set up response handler
605- (window as any).__dumber_history_search = (data: any[]) => {
606- // Sort by visit_count in descending order and take top 5
607- const sortedData = Array.isArray(data) ?
608- data.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0)).slice(0, 5) : [];
609- topVisited = sortedData;
610- topVisitedLoading = false;
611- };
612-
613- (window as any).__dumber_history_error = (error: string) => {
614- console.error('Error fetching top visited:', error);
615- topVisited = [];
616- topVisitedLoading = false;
617- };
618-
619- // Send message to Go backend
620- const bridge = (window as any).webkit?.messageHandlers?.dumber;
621- if (bridge && typeof bridge.postMessage === 'function') {
622- bridge.postMessage(JSON.stringify({
623- type: 'history_search',
624- q: '',
625- limit: 50 // Get more results to sort properly
626- }));
627- } else {
628- throw new Error('WebKit message handler not available');
629- }
630- } catch (error) {
631- console.error('Error sending top visited request:', error);
632- topVisited = [];
633- topVisitedLoading = false;
634- }
635- };
636-
637- // Fetch search shortcuts via message bridge
638- const fetchSearchShortcuts = async () => {
639- try {
640- // Set up response handler
641- (window as any).__dumber_search_shortcuts = (data: unknown) => {
642- try {
643- if (typeof data !== 'object' || data === null) {
644- throw new Error('Invalid shortcuts data');
645- }
646- const dataObj = data as Record<string, unknown>;
647- const normalized: Record<string, SearchShortcut> = {};
648- for (const [key, value] of Object.entries(dataObj)) {
649- const v = value as Record<string, unknown>;
650- normalized[key] = {
651- url: (v.url ?? v.URL ?? '') as string,
652- description: (v.description ?? v.Description ?? '') as string,
653- };
654- }
655- searchShortcuts = normalized;
656- searchShortcutsLoading = false;
657- } catch (error) {
658- console.error('Failed to process search shortcuts:', error);
659- searchShortcuts = {};
660- searchShortcutsLoading = false;
661- }
662- };
663-
664- // Send message to Go backend
665- const bridge = (window as any).webkit?.messageHandlers?.dumber;
666- if (bridge && typeof bridge.postMessage === 'function') {
667- bridge.postMessage(JSON.stringify({
668- type: 'get_search_shortcuts'
669- }));
670- } else {
671- throw new Error('WebKit message handler not available');
672- }
673- } catch (error) {
674- console.error('Error fetching search shortcuts:', error);
675- searchShortcuts = {};
676- searchShortcutsLoading = false;
677- }
678- };
679-
680- // Intersection Observer for infinite scroll
681- let historyScrollSentinel = $state<HTMLElement | null>(null);
682-
683- const setupInfiniteScroll = () => {
684- if (!historyScrollSentinel) return;
685-
686- const observer = new IntersectionObserver(
687- (entries) => {
688- const entry = entries[0];
689- if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
690- loadMoreHistory();
691- }
692- },
693- {
694- threshold: 0.1,
695- rootMargin: '50px'
696- }
697- );
698-
699- observer.observe(historyScrollSentinel);
700-
701- return () => observer.disconnect();
702- };
703-
704- // Helper to check if text overflows and apply truncation class
705- const checkOverflow = (node: HTMLElement) => {
706- const check = () => {
707- // Add 1px tolerance for sub-pixel rendering differences
708- // This prevents false positives from rounding errors
709- const isOverflowing = node.scrollWidth > (node.clientWidth + 1);
710-
711- if (isOverflowing) {
712- node.classList.add('truncated');
713- } else {
714- node.classList.remove('truncated');
715- }
716- };
717-
718- // Delay initial check to ensure layout is complete
719- requestAnimationFrame(() => {
720- requestAnimationFrame(check);
721- });
722-
723- const resizeObserver = new ResizeObserver(() => {
724- requestAnimationFrame(check);
725- });
726-
727- resizeObserver.observe(node);
728-
729- return {
730- destroy() {
731- resizeObserver.disconnect();
732- }
733- };
734- };
735-
736- onMount(() => {
737- loadPinnedSites();
738- initializeShortcuts();
739- fetchHistory().then(() => {
740- // Setup infinite scroll after initial history is loaded
741- setTimeout(setupInfiniteScroll, 100);
742- });
743- fetchStats();
744- fetchTopVisited();
745- fetchSearchShortcuts();
746-
747- // Theme synchronization
748- const syncThemeState = () => {
749- themeMode = document.documentElement.classList.contains("dark")
750- ? "dark"
751- : "light";
752- };
753- syncThemeState();
754- themeObserver = new MutationObserver(syncThemeState);
755- themeObserver.observe(document.documentElement, {
756- attributes: true,
757- attributeFilter: ["class"],
758- });
759-
760- return () => {
761- themeObserver?.disconnect();
762- themeObserver = null;
763- };
764- });
765-
766-
767 </script>5 </script>
768 6
769-<svelte:head>7+<HomepageShell>
770- <title>Dumber Browser - Homepage</title>8+ {#if homepageState.activePanel === 'history'}
771- <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />9+ <HistoryPanel />
772-10+ {:else if homepageState.activePanel === 'favorites'}
773- {@html `<style>11+ <FavoritesPanel />
774- html { background: var(--dynamic-bg, #0a0a0a); }12+ {:else if homepageState.activePanel === 'analytics'}
775- body { background: var(--dynamic-bg, #0a0a0a); color: var(--dynamic-text, #e5e5e5); }13+ <AnalyticsPanel />
776- </style>`}14+ {/if}
777-15+</HomepageShell>
778- {@html `<script>
779- (function() {
780- const theme = localStorage.getItem('dumber.theme');
781- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
782- const shouldBeLight = theme === 'light' || (theme !== 'dark' && !prefersDark);
783- if (shouldBeLight) {
784- document.documentElement.classList.add('light');
785- }
786- })();
787- </script>`}
788-</svelte:head>
789-
790-<div class="homepage-shell">
791- <div class="terminal-frame">
792- <header class="terminal-header">
793- <div class="terminal-heading">
794- <span class="terminal-path">dumb://home</span>
795- <span class="terminal-meta">
796- {statsLoading ? 'syncing…' : `${stats?.recent_count ?? 0} recent`}
797- · {historyItems.length} entries
798- · {pinnedSites.size} pinned
799- </span>
800- </div>
801- <button
802- class="theme-toggle-button"
803- type="button"
804- aria-pressed={themeMode === "dark"}
805- title={themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
806- onclick={toggleTheme}
807- >
808- <span class="sr-only">
809- {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
810- </span>
811- {#if themeMode === "dark"}
812- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
813- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
814- </svg>
815- {:else}
816- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
817- <circle cx="12" cy="12" r="5" fill="currentColor" />
818- <g stroke="currentColor" stroke-width="2" stroke-linecap="round">
819- <line x1="12" y1="1" x2="12" y2="4" />
820- <line x1="12" y1="20" x2="12" y2="23" />
821- <line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
822- <line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
823- <line x1="1" y1="12" x2="4" y2="12" />
824- <line x1="20" y1="12" x2="23" y2="12" />
825- <line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
826- <line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
827- </g>
828- </svg>
829- {/if}
830- </button>
831- </header>
832-
833- <div class="terminal-body">
834- <div class="terminal-status-row">
835- <div class="status-chip">
836- <span class="chip-label">history</span>
837- <span class="chip-value">{historyItems.length}</span>
838- </div>
839- <div class="status-chip">
840- <span class="chip-label">pinned</span>
841- <span class="chip-value">{pinnedSites.size}</span>
842- </div>
843- <div class="status-chip">
844- <span class="chip-label">quick-links</span>
845- <span class="chip-value">{quickAccess.length}</span>
846- </div>
847- {#if stats && stats.total_entries}
848- <div class="status-chip">
849- <span class="chip-label">stored</span>
850- <span class="chip-value">{stats.total_entries}</span>
851- </div>
852- {/if}
853- </div>
854-
855- <div
856- class="last-visit-banner"
857- role="button"
858- tabindex={latestVisitInfo ? 0 : -1}
859- onclick={() => latestVisitInfo && navigateTo(latestVisitInfo.entry.url)}
860- onkeydown={(event) => {
861- if (event.key === 'Enter' && latestVisitInfo) {
862- navigateTo(latestVisitInfo.entry.url);
863- }
864- }}
865- aria-label={latestVisitInfo ? `Open ${latestVisitInfo.title}` : 'No recorded visits'}
866- >
867- <span class="banner-label">last-visit</span>
868- {#if latestVisitInfo}
869- <span class="banner-title" use:checkOverflow>
870- {latestVisitInfo.title}
871- </span>
872- <span class="banner-domain">{latestVisitInfo.domain}</span>
873- <span class="banner-time">{latestVisitInfo.relative}</span>
874- <span class="banner-absolute">{latestVisitInfo.absolute}</span>
875- {:else}
876- <span class="banner-empty">none recorded yet</span>
877- {/if}
878- </div>
879-
880- <div class="terminal-grid main-panels">
881- <section class="history-panel terminal-pane">
882- <div class="panel-header">
883- <h2 class="panel-title">Recent History</h2>
884- <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
885- </div>
886- <div class="panel-body history-body">
887- {#if historyLoading}
888- <div class="loading">Loading history...</div>
889- {:else if historyItems.length === 0}
890- <div class="empty-state">
891- <h3>No history yet</h3>
892- <p>Start browsing to see your recent history here.</p>
893- </div>
894- {:else}
895- <div class="history-list">
896- {#each displayedHistoryItems as item (item.id)}
897- <div
898- class="history-item"
899- class:deleting={isDeleting(item.id)}
900- role="button"
901- tabindex="0"
902- onclick={() => navigateTo(item.url)}
903- onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
904- >
905- <div class="history-item-leading">
906- {#if item.favicon_url}
907- <img
908- src={item.favicon_url}
909- alt=""
910- class="history-favicon-img"
911- onerror={(e) => {
912- const target = e.target as HTMLImageElement;
913- if (target) {
914- target.style.display = 'none';
915- const fallback = target.nextElementSibling as HTMLElement;
916- if (fallback) fallback.style.display = 'flex';
917- }
918- }}
919- onload={(e) => {
920- const target = e.target as HTMLImageElement;
921- if (target) {
922- const fallback = target.nextElementSibling as HTMLElement;
923- if (fallback) fallback.style.display = 'none';
924- }
925- }}
926- />
927- <div class="history-favicon-fallback" style="display: none;">
928- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
929- <circle cx="12" cy="12" r="10" />
930- <path d="M2 12h20" />
931- </svg>
932- </div>
933- {:else}
934- <div class="history-favicon-fallback">
935- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
936- <circle cx="12" cy="12" r="10" />
937- <path d="M2 12h20" />
938- </svg>
939- </div>
940- {/if}
941- </div>
942- <div class="history-item-content">
943- <div class="history-item-main">
944- <div class="history-title" use:checkOverflow>
945- {getDisplayTitle(item)}
946- </div>
947- <div class="history-time">{formatTime(item.last_visited)}</div>
948- </div>
949- <div class="history-url" use:checkOverflow>
950- {@html highlightDomainInUrl(item.url)}
951- </div>
952- </div>
953- <div class="history-actions">
954- <div
955- class="history-pin"
956- role="button"
957- tabindex="0"
958- onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
959- onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
960- title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
961- >
962- {#if isPinned(item.url)}
963- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
964- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
965- </svg>
966- {:else}
967- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">
968- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
969- </svg>
970- {/if}
971- </div>
972- <div
973- class="history-delete"
974- role="button"
975- tabindex="0"
976- onclick={(e) => deleteHistoryEntry(item.id, e)}
977- onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
978- title="Delete this entry"
979- >
980- <span aria-hidden="true">×</span>
981- </div>
982- </div>
983- </div>
984- {/each}
985-
986- {#if !showAllHistory && historyItems.length > INITIAL_HISTORY_DISPLAY}
987- <button
988- class="show-more-button"
989- onclick={() => showAllHistory = true}
990- type="button"
991- >
992- Show {historyItems.length - INITIAL_HISTORY_DISPLAY} more items
993- </button>
994- {:else if showAllHistory && hasMoreHistory}
995- <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
996- {#if loadingMoreHistory}
997- <div class="loading-more">Loading more history...</div>
998- {/if}
999- {/if}
1000- </div>
1001- {/if}
1002- </div>
1003- </section>
1004-
1005- <section class="quick-access-panel terminal-pane">
1006- <div class="panel-header">
1007- <h2 class="panel-title">Jump back in</h2>
1008- <p class="panel-subtitle">Direct access to the sites you like the most.</p>
1009- </div>
1010- <div class="panel-body quick-access-body">
1011- {#if quickAccessLoading}
1012- <div class="loading">Preparing your shortcuts...</div>
1013- {:else if quickAccess.length === 0}
1014- <div class="empty-state compact">
1015- <h3>No quick links yet</h3>
1016- <p>Browse a few sites and we'll surface your frequent destinations here.</p>
1017- </div>
1018- {:else}
1019- <div class="quick-access-grid-compact">
1020- {#each quickAccess as item (item.id)}
1021- <div
1022- class="quick-access-item-compact"
1023- role="button"
1024- tabindex="0"
1025- onclick={() => navigateTo(item.url)}
1026- onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
1027- >
1028- <div class="quick-access-favicon-compact">
1029- {#if item.favicon_url}
1030- <img
1031- src={item.favicon_url}
1032- alt=""
1033- class="quick-access-favicon-img-compact"
1034- onerror={(e) => {
1035- const target = e.target as HTMLImageElement;
1036- if (target) {
1037- target.style.display = 'none';
1038- const fallback = target.nextElementSibling as HTMLElement;
1039- if (fallback) fallback.style.display = 'flex';
1040- }
1041- }}
1042- onload={(e) => {
1043- const target = e.target as HTMLImageElement;
1044- if (target) {
1045- const fallback = target.nextElementSibling as HTMLElement;
1046- if (fallback) fallback.style.display = 'none';
1047- }
1048- }}
1049- />
1050- <div class="quick-access-fallback-compact" style="display: none;">
1051- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1052- <circle cx="12" cy="12" r="10" />
1053- <path d="M2 12h20" />
1054- </svg>
1055- </div>
1056- {:else}
1057- <div class="quick-access-fallback-compact">
1058- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1059- <circle cx="12" cy="12" r="10" />
1060- <path d="M2 12h20" />
1061- </svg>
1062- </div>
1063- {/if}
1064- </div>
1065- <div class="quick-access-meta-compact">
1066- <div class="quick-access-title-compact" use:checkOverflow>
1067- {getDisplayTitle(item)}
1068- </div>
1069- <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
1070- </div>
1071- <div class="quick-access-actions-compact">
1072- <div
1073- class="quick-access-pin-compact"
1074- role="button"
1075- tabindex="0"
1076- onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
1077- onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
1078- title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
1079- >
1080- {#if isPinned(item.url)}
1081- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
1082- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
1083- </svg>
1084- {:else}
1085- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">
1086- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
1087- </svg>
1088- {/if}
1089- </div>
1090- <div class="quick-access-visits-compact">{formatVisitLabel(item.visit_count)}</div>
1091- </div>
1092- </div>
1093- {/each}
1094- </div>
1095- {/if}
1096- </div>
1097- </section>
1098- </div>
1099-
1100- <section class="search-shortcuts-panel terminal-pane full-width">
1101- <div class="panel-header">
1102- <h2 class="panel-title">Search Shortcuts</h2>
1103- <p class="panel-subtitle">Quick search prefixes for faster browsing.</p>
1104- </div>
1105- <div class="panel-body">
1106- {#if searchShortcutsLoading}
1107- <div class="loading">Loading search shortcuts...</div>
1108- {:else if Object.keys(searchShortcuts).length === 0}
1109- <div class="empty-state compact">
1110- <h3>No shortcuts configured</h3>
1111- <p>Add search shortcuts to your config file.</p>
1112- </div>
1113- {:else}
1114- <div class="shortcuts-list">
1115- {#each Object.entries(searchShortcuts) as [key, shortcut] (key)}
1116- <div class="shortcut-item">
1117- <div class="shortcut-key-badge">{key}:</div>
1118- <div class="shortcut-desc">{shortcut.description.toLowerCase()}</div>
1119- </div>
1120- {/each}
1121- </div>
1122- {/if}
1123- </div>
1124- </section>
1125-
1126- <section class="shortcuts-panel terminal-pane full-width">
1127- <div class="panel-header">
1128- <h2 class="panel-title">Keyboard Shortcuts</h2>
1129- <p class="panel-subtitle">Stay quick with the essentials.</p>
1130- </div>
1131- <div class="panel-body">
1132- {#if shortcutsLoading}
1133- <div class="loading">Loading shortcuts...</div>
1134- {:else}
1135- <div class="shortcuts-list">
1136- {#each shortcuts as shortcut (shortcut.key)}
1137- <div class="shortcut-item">
1138- <div class="shortcut-key-badge">{shortcut.key}</div>
1139- <div class="shortcut-desc">{shortcut.description}</div>
1140- </div>
1141- {/each}
1142- </div>
1143- {/if}
1144- </div>
1145- </section>
1146-
1147- <section class="stats-panel terminal-pane full-width">
1148- <div class="panel-header">
1149- <h2 class="panel-title">Usage Stats</h2>
1150- <p class="panel-subtitle">Your browsing activity at a glance.</p>
1151- </div>
1152- <div class="panel-body">
1153- {#if statsLoading}
1154- <div class="loading">Loading stats...</div>
1155- {:else if stats}
1156- <div class="stats-grid-horizontal">
1157- <div class="stat-item-horizontal">
1158- <div class="stat-value">{stats.total_entries}</div>
1159- <div class="stat-label">Entries Stored</div>
1160- </div>
1161- <div class="stat-item-horizontal">
1162- <div class="stat-value">{stats.recent_count}</div>
1163- <div class="stat-label">Recent Window</div>
1164- </div>
1165- {#if stats.newest_entry}
1166- <div class="stat-item-horizontal">
1167- <div class="stat-value">{formatTime(stats.newest_entry)}</div>
1168- <div class="stat-label">Newest Visit</div>
1169- </div>
1170- {/if}
1171- {#if stats.oldest_entry}
1172- <div class="stat-item-horizontal">
1173- <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
1174- <div class="stat-label">Oldest Entry</div>
1175- </div>
1176- {/if}
1177- </div>
1178- {:else}
1179- <div class="empty-state compact">
1180- <p>No statistics available</p>
1181- </div>
1182- {/if}
1183- </div>
1184- </section>
1185- </div>
1186- </div>
1187-
1188- <Footer />
1189-</div>
1190-
1191-<style>
1192-.homepage-shell {
1193- min-height: 100vh;
1194- color: var(--dynamic-text);
1195- display: flex;
1196- flex-direction: column;
1197- padding: clamp(1.5rem, 4vw, 2.5rem);
1198- font-family:
1199- "JetBrains Mono",
1200- "Fira Code",
1201- "SFMono-Regular",
1202- Menlo,
1203- monospace;
1204- line-height: 1.45;
1205-}
1206-
1207-.homepage-shell * {
1208- box-sizing: border-box;
1209-}
1210-
1211-.terminal-frame {
1212- flex: 1;
1213- width: 100%;
1214- max-width: 1160px;
1215- margin: 0 auto;
1216- display: flex;
1217- flex-direction: column;
1218- border: 1px solid var(--dynamic-border);
1219- background: color-mix(in srgb, var(--dynamic-surface) 80%, var(--dynamic-bg) 20%);
1220- box-shadow:
1221- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 30%, transparent),
1222- 0 16px 28px -24px rgb(0 0 0 / 0.6);
1223-}
1224-
1225-.terminal-header {
1226- display: flex;
1227- align-items: center;
1228- justify-content: space-between;
1229- gap: 1rem;
1230- padding: 0.85rem 1.1rem;
1231- border-bottom: 1px solid var(--dynamic-border);
1232- background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1233- text-transform: uppercase;
1234- letter-spacing: 0.08em;
1235- font-size: 0.78rem;
1236-}
1237-
1238-.terminal-heading {
1239- display: flex;
1240- flex-direction: column;
1241- gap: 0.35rem;
1242-}
1243-
1244-.terminal-path {
1245- font-weight: 600;
1246- color: var(--dynamic-text);
1247-}
1248-
1249-.terminal-meta {
1250- color: var(--dynamic-muted);
1251- font-size: 0.72rem;
1252- letter-spacing: 0.12em;
1253-}
1254-
1255-.theme-toggle-button {
1256- width: 2.5rem;
1257- height: 2.5rem;
1258- display: inline-flex;
1259- align-items: center;
1260- justify-content: center;
1261- border: 1px solid var(--dynamic-border);
1262- background-color: var(--dynamic-bg);
1263- color: var(--dynamic-muted);
1264- transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease;
1265- border-radius: 0;
1266- cursor: pointer;
1267-}
1268-
1269-.theme-toggle-button:hover,
1270-.theme-toggle-button:focus-visible {
1271- border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1272- color: var(--dynamic-text);
1273- background-color: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1274- outline: none;
1275-}
1276-
1277-.theme-toggle-button svg {
1278- width: 1.3rem;
1279- height: 1.3rem;
1280-}
1281-
1282-.sr-only {
1283- position: absolute;
1284- width: 1px;
1285- height: 1px;
1286- padding: 0;
1287- margin: -1px;
1288- overflow: hidden;
1289- clip: rect(0, 0, 0, 0);
1290- white-space: nowrap;
1291- border: 0;
1292-}
1293-
1294-.terminal-body {
1295- display: flex;
1296- flex-direction: column;
1297- gap: 1.25rem;
1298- padding: 1.25rem 1.5rem 1.5rem;
1299- background: var(--dynamic-bg);
1300-}
1301-
1302-.terminal-status-row {
1303- display: flex;
1304- flex-wrap: wrap;
1305- gap: 0.75rem;
1306-}
1307-
1308-.status-chip {
1309- display: inline-flex;
1310- align-items: center;
1311- gap: 0.5rem;
1312- padding: 0.45rem 0.8rem;
1313- border: 1px solid var(--dynamic-border);
1314- background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1315- font-size: 0.68rem;
1316- letter-spacing: 0.1em;
1317- text-transform: uppercase;
1318- color: var(--dynamic-muted);
1319-}
1320-
1321-.status-chip .chip-value {
1322- color: var(--dynamic-text);
1323- font-weight: 600;
1324-}
1325-
1326-.last-visit-banner {
1327- margin-top: 0.9rem;
1328- display: grid;
1329- grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(120px, auto) minmax(120px, auto) minmax(120px, auto);
1330- gap: 0.75rem;
1331- align-items: center;
1332- padding: 0.75rem 1rem;
1333- border: 1px solid var(--dynamic-border);
1334- background: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
1335- color: var(--dynamic-text);
1336- text-transform: uppercase;
1337- letter-spacing: 0.08em;
1338- transition: background-color 150ms ease, border-color 150ms ease;
1339- cursor: pointer;
1340-}
1341-
1342-.last-visit-banner:hover,
1343-.last-visit-banner:focus-visible {
1344- background: color-mix(in srgb, var(--dynamic-bg) 72%, var(--dynamic-surface) 28%);
1345- border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1346- outline: none;
1347-}
1348-
1349-.last-visit-banner[tabindex="-1"],
1350-.last-visit-banner[tabindex="-1"]:hover {
1351- cursor: default;
1352- background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1353-}
1354-
1355-.banner-label {
1356- color: var(--dynamic-muted);
1357- font-size: 0.68rem;
1358-}
1359-
1360-.banner-title {
1361- font-weight: 600;
1362- white-space: nowrap;
1363- overflow: hidden;
1364- letter-spacing: 0.06em;
1365-}
1366-
1367-:global(.banner-title.truncated) {
1368- mask-image: linear-gradient(to right, black 85%, transparent 100%);
1369- -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1370-}
1371-
1372-.banner-domain {
1373- color: var(--dynamic-muted);
1374- font-size: 0.68rem;
1375- letter-spacing: 0.1em;
1376-}
1377-
1378-.banner-time {
1379- font-size: 0.72rem;
1380- color: var(--dynamic-text);
1381- white-space: nowrap;
1382-}
1383-
1384-.banner-absolute {
1385- font-size: 0.68rem;
1386- color: var(--dynamic-muted);
1387- white-space: nowrap;
1388-}
1389-
1390-.banner-empty {
1391- grid-column: 2 / -1;
1392- color: var(--dynamic-muted);
1393- font-size: 0.72rem;
1394- letter-spacing: 0.1em;
1395-}
1396-
1397-.terminal-grid {
1398- display: grid;
1399- gap: 1rem;
1400-}
1401-
1402-.main-panels {
1403- grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
1404-}
1405-
1406-.terminal-pane {
1407- border: 1px solid var(--dynamic-border);
1408- background: color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%);
1409- display: flex;
1410- flex-direction: column;
1411- min-height: 0;
1412- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 18%, transparent);
1413-}
1414-
1415-.terminal-pane.full-width {
1416- width: 100%;
1417- grid-column: 1 / -1;
1418-}
1419-
1420-.panel-header {
1421- display: flex;
1422- flex-direction: column;
1423- gap: 0.3rem;
1424- padding: 1rem 1.1rem;
1425- border-bottom: 1px solid var(--dynamic-border);
1426- background: color-mix(in srgb, var(--dynamic-bg) 75%, transparent);
1427-}
1428-
1429-.panel-title {
1430- margin: 0;
1431- font-size: 0.95rem;
1432- text-transform: uppercase;
1433- letter-spacing: 0.08em;
1434- color: var(--dynamic-text);
1435-}
1436-
1437-.panel-subtitle {
1438- margin: 0;
1439- font-size: 0.72rem;
1440- color: var(--dynamic-muted);
1441- letter-spacing: 0.1em;
1442- text-transform: uppercase;
1443-}
1444-
1445-.panel-body {
1446- padding: 1.1rem 1.1rem 1.25rem;
1447- display: flex;
1448- flex-direction: column;
1449- gap: 1.1rem;
1450- min-height: 0;
1451- overflow: hidden;
1452-}
1453-
1454-.loading {
1455- padding: 0.9rem;
1456- text-align: center;
1457- color: var(--dynamic-muted);
1458- border: 1px dashed var(--dynamic-border);
1459- background-color: color-mix(in srgb, var(--dynamic-bg) 85%, var(--dynamic-surface) 15%);
1460- text-transform: uppercase;
1461- letter-spacing: 0.08em;
1462- font-size: 0.7rem;
1463-}
1464-
1465-.empty-state {
1466- border: 1px dashed var(--dynamic-border);
1467- padding: 1.4rem;
1468- display: flex;
1469- flex-direction: column;
1470- gap: 0.5rem;
1471- align-items: center;
1472- text-align: center;
1473- color: var(--dynamic-muted);
1474- background-color: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
1475- letter-spacing: 0.06em;
1476-}
1477-
1478-.empty-state h3 {
1479- margin: 0;
1480- color: var(--dynamic-text);
1481- font-size: 0.9rem;
1482- text-transform: uppercase;
1483-}
1484-
1485-.empty-state p {
1486- margin: 0;
1487- font-size: 0.72rem;
1488-}
1489-
1490-.empty-state.compact {
1491- padding: 1rem;
1492-}
1493-
1494-.history-body {
1495- gap: 0.75rem;
1496- display: flex;
1497- flex-direction: column;
1498- min-height: 0;
1499- overflow: hidden;
1500-}
1501-
1502-.history-list {
1503- display: flex;
1504- flex-direction: column;
1505- flex: 1;
1506- min-height: 0;
1507- overflow-y: auto;
1508- border: 1px solid var(--dynamic-border);
1509- background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1510-}
1511-
1512-.history-item {
1513- display: grid;
1514- grid-template-columns: auto 1fr auto;
1515- align-items: center;
1516- gap: 0.75rem;
1517- padding: 0.6rem 0.9rem;
1518- border-bottom: 1px dashed var(--dynamic-border);
1519- transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
1520-}
1521-
1522-.history-item:last-child {
1523- border-bottom: none;
1524-}
1525-
1526-.history-item:hover,
1527-.history-item:focus-visible {
1528- background: color-mix(in srgb, var(--dynamic-bg) 65%, var(--dynamic-surface) 35%);
1529- outline: none;
1530-}
1531-
1532-.history-item.deleting {
1533- opacity: 0.35;
1534- pointer-events: none;
1535- transform: translateX(4px);
1536-}
1537-
1538-.history-item-leading {
1539- width: 38px;
1540- height: 38px;
1541- border: 1px solid var(--dynamic-border);
1542- background-color: var(--dynamic-bg);
1543- display: flex;
1544- align-items: center;
1545- justify-content: center;
1546- flex-shrink: 0;
1547-}
1548-
1549-.history-favicon-img {
1550- width: 60%;
1551- height: 60%;
1552- object-fit: contain;
1553-}
1554-
1555-.history-favicon-fallback {
1556- display: flex;
1557- align-items: center;
1558- justify-content: center;
1559- width: 100%;
1560- height: 100%;
1561- color: var(--dynamic-muted);
1562-}
1563-
1564-.history-favicon-fallback svg {
1565- opacity: 0.55;
1566-}
1567-
1568-.history-item-content {
1569- display: flex;
1570- flex-direction: column;
1571- gap: 0.2rem;
1572- min-width: 0;
1573-}
1574-
1575-.history-item-main {
1576- display: flex;
1577- align-items: center;
1578- justify-content: space-between;
1579- gap: 0.75rem;
1580-}
1581-
1582-.history-title {
1583- font-weight: 600;
1584- color: var(--dynamic-text);
1585- white-space: nowrap;
1586- overflow: hidden;
1587-}
1588-
1589-:global(.history-title.truncated) {
1590- mask-image: linear-gradient(to right, black 85%, transparent 100%);
1591- -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1592-}
1593-
1594-.history-title:hover {
1595- cursor: pointer;
1596-}
1597-
1598-.history-time {
1599- font-size: 0.72rem;
1600- color: var(--dynamic-muted);
1601- text-transform: uppercase;
1602- letter-spacing: 0.08em;
1603- white-space: nowrap;
1604-}
1605-
1606-.history-url {
1607- font-size: 0.72rem;
1608- color: var(--dynamic-muted);
1609- white-space: nowrap;
1610- overflow: hidden;
1611-}
1612-
1613-:global(.history-url.truncated) {
1614- mask-image: linear-gradient(to right, black 85%, transparent 100%);
1615- -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1616-}
1617-
1618-.history-url :global(.history-url-domain) {
1619- color: var(--dynamic-text) !important;
1620- font-weight: 600;
1621-}
1622-
1623-.history-actions {
1624- display: flex;
1625- gap: 0.35rem;
1626-}
1627-
1628-.history-pin,
1629-.history-delete {
1630- display: inline-flex;
1631- align-items: center;
1632- justify-content: center;
1633- width: 28px;
1634- height: 28px;
1635- border: 1px solid var(--dynamic-border);
1636- color: var(--dynamic-muted);
1637- background: transparent;
1638- transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
1639-}
1640-
1641-.history-pin:hover,
1642-.history-pin:focus-visible,
1643-.history-delete:hover,
1644-.history-delete:focus-visible {
1645- color: var(--dynamic-text);
1646- border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1647- background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1648- outline: none;
1649-}
1650-
1651-.scroll-sentinel {
1652- height: 1px;
1653-}
1654-
1655-.loading-more {
1656- padding: 0.75rem;
1657- font-size: 0.7rem;
1658- text-align: center;
1659- color: var(--dynamic-muted);
1660- letter-spacing: 0.08em;
1661- text-transform: uppercase;
1662-}
1663-
1664-.quick-access-body {
1665- gap: 0.9rem;
1666-}
1667-
1668-.quick-access-grid-compact {
1669- display: flex;
1670- flex-direction: column;
1671- gap: 0.75rem;
1672-}
1673-
1674-.quick-access-item-compact {
1675- border: 1px solid var(--dynamic-border);
1676- background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1677- padding: 0.75rem 0.9rem;
1678- display: grid;
1679- grid-template-columns: auto 1fr auto;
1680- gap: 0.75rem;
1681- align-items: center;
1682- transition: background-color 150ms ease, border-color 150ms ease;
1683- cursor: pointer;
1684-}
1685-
1686-.quick-access-item-compact:hover,
1687-.quick-access-item-compact:focus-visible {
1688- border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1689- background: color-mix(in srgb, var(--dynamic-bg) 70%, var(--dynamic-surface) 30%);
1690- outline: none;
1691-}
1692-
1693-.quick-access-favicon-compact {
1694- width: 36px;
1695- height: 36px;
1696- border: 1px solid var(--dynamic-border);
1697- display: flex;
1698- align-items: center;
1699- justify-content: center;
1700- background: var(--dynamic-bg);
1701-}
1702-
1703-.quick-access-favicon-img-compact {
1704- width: 60%;
1705- height: 60%;
1706- object-fit: contain;
1707-}
1708-
1709-.quick-access-fallback-compact {
1710- display: flex;
1711- align-items: center;
1712- justify-content: center;
1713- width: 100%;
1714- height: 100%;
1715- color: var(--dynamic-muted);
1716-}
1717-
1718-.quick-access-fallback-compact svg {
1719- opacity: 0.55;
1720-}
1721-
1722-.quick-access-meta-compact {
1723- display: flex;
1724- flex-direction: column;
1725- gap: 0.2rem;
1726- min-width: 0;
1727-}
1728-
1729-.quick-access-title-compact {
1730- color: var(--dynamic-text);
1731- font-weight: 600;
1732- white-space: nowrap;
1733- overflow: hidden;
1734-}
1735-
1736-:global(.quick-access-title-compact.truncated) {
1737- mask-image: linear-gradient(to right, black 85%, transparent 100%);
1738- -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1739-}
1740-
1741-.quick-access-title-compact:hover {
1742- cursor: pointer;
1743-}
1744-
1745-.quick-access-domain-compact {
1746- color: var(--dynamic-muted);
1747- font-size: 0.72rem;
1748- text-transform: uppercase;
1749- letter-spacing: 0.08em;
1750-}
1751-
1752-.quick-access-actions-compact {
1753- display: flex;
1754- flex-direction: column;
1755- gap: 0.3rem;
1756- align-items: flex-end;
1757- justify-content: center;
1758-}
1759-
1760-.quick-access-pin-compact {
1761- display: inline-flex;
1762- align-items: center;
1763- justify-content: center;
1764- width: 26px;
1765- height: 26px;
1766- border: 1px solid var(--dynamic-border);
1767- color: var(--dynamic-muted);
1768- background: transparent;
1769- transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
1770- cursor: pointer;
1771-}
1772-
1773-.quick-access-pin-compact:hover,
1774-.quick-access-pin-compact:focus-visible {
1775- color: var(--dynamic-text);
1776- border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1777- background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1778- outline: none;
1779-}
1780-
1781-.quick-access-pin-compact svg {
1782- width: 14px;
1783- height: 14px;
1784-}
1785-
1786-.quick-access-visits-compact {
1787- font-size: 0.7rem;
1788- color: var(--dynamic-muted);
1789- text-transform: uppercase;
1790- letter-spacing: 0.08em;
1791-}
1792-
1793-.show-more-button {
1794- width: 100%;
1795- padding: 0.65rem 0.75rem;
1796- border: 1px solid var(--dynamic-border);
1797- background: transparent;
1798- color: var(--dynamic-muted);
1799- font-size: 0.72rem;
1800- text-transform: uppercase;
1801- letter-spacing: 0.08em;
1802- cursor: pointer;
1803- transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;
1804- border-radius: 0;
1805-}
1806-
1807-.show-more-button:hover,
1808-.show-more-button:focus-visible {
1809- background: color-mix(in srgb, var(--dynamic-bg) 75%, var(--dynamic-surface) 25%);
1810- color: var(--dynamic-text);
1811- border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1812- outline: none;
1813-}
1814-
1815-.stats-grid-horizontal {
1816- display: grid;
1817- gap: 0.85rem;
1818- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1819-}
1820-
1821-.stat-item-horizontal {
1822- border: 1px solid var(--dynamic-border);
1823- padding: 0.85rem;
1824- display: flex;
1825- flex-direction: column;
1826- gap: 0.4rem;
1827- background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1828- text-transform: uppercase;
1829- letter-spacing: 0.08em;
1830-}
1831-
1832-.stat-value {
1833- font-size: 0.95rem;
1834- color: var(--dynamic-text);
1835- font-weight: 600;
1836-}
1837-
1838-.stat-label {
1839- font-size: 0.68rem;
1840- color: var(--dynamic-muted);
1841-}
1842-
1843-.shortcuts-list {
1844- display: grid;
1845- grid-template-columns: repeat(2, 1fr);
1846- gap: 0.6rem;
1847-}
1848-
1849-.shortcut-item {
1850- display: grid;
1851- grid-template-columns: auto 1fr;
1852- gap: 0.6rem;
1853- align-items: center;
1854- border: 1px solid var(--dynamic-border);
1855- padding: 0.6rem 0.75rem;
1856- background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1857-}
1858-
1859-.shortcut-key-badge {
1860- font-size: 0.72rem;
1861- text-transform: uppercase;
1862- letter-spacing: 0.08em;
1863- padding: 0.25rem 0.6rem;
1864- border: 1px solid var(--dynamic-border);
1865- color: var(--dynamic-text);
1866- background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1867-}
1868-
1869-.shortcut-desc {
1870- font-size: 0.72rem;
1871- color: var(--dynamic-muted);
1872- letter-spacing: 0.06em;
1873-}
1874-
1875-@media (max-width: 960px) {
1876- .terminal-body {
1877- padding: 1.1rem;
1878- }
1879-
1880- .terminal-grid {
1881- gap: 0.75rem;
1882- }
1883-
1884- .main-panels {
1885- grid-template-columns: 1fr;
1886- }
1887-
1888- .last-visit-banner {
1889- grid-template-columns: minmax(0, auto) minmax(0, 1fr);
1890- grid-template-rows: repeat(3, auto);
1891- gap: 0.5rem 0.75rem;
1892- }
1893-
1894- .banner-time,
1895- .banner-absolute {
1896- justify-self: flex-start;
1897- }
1898-}
1899-
1900-@media (max-width: 640px) {
1901- .homepage-shell {
1902- padding: 1.25rem;
1903- }
1904-
1905- .terminal-header {
1906- flex-direction: column;
1907- align-items: flex-start;
1908- }
1909-
1910- .theme-toggle-button {
1911- align-self: flex-end;
1912- }
1913-
1914- .terminal-status-row {
1915- gap: 0.5rem;
1916- }
1917-
1918- .status-chip {
1919- padding: 0.35rem 0.65rem;
1920- }
1921-
1922- .last-visit-banner {
1923- grid-template-columns: minmax(0, 1fr);
1924- grid-template-rows: repeat(4, auto);
1925- }
1926-
1927- .banner-label,
1928- .banner-title,
1929- .banner-domain,
1930- .banner-time,
1931- .banner-absolute {
1932- justify-self: flex-start;
1933- }
1934-
1935- .shortcuts-list {
1936- grid-template-columns: 1fr;
1937- }
1938-}
1939-</style>
A gui/src/pages/blocked.ts +15 -0
@@ -0,0 +1,15 @@
1+import { mount } from "svelte";
2+import "../styles/tailwind.css";
3+import BlockedPage from "./BlockedPage.svelte";
4+import { bootstrapGUI } from "../injected/bootstrap";
5+
6+bootstrapGUI();
7+
8+// Mount the BlockedPage component to the DOM
9+console.log("[dumber] Mounting BlockedPage component to document.body");
10+try {
11+ const app = mount(BlockedPage, { target: document.body });
12+ console.log("[dumber] BlockedPage component mounted successfully", app);
13+} catch (error) {
14+ console.error("[dumber] Failed to mount BlockedPage component:", error);
15+}
A gui/src/pages/homepage/favorites/FavoriteCard.svelte +260 -0
@@ -0,0 +1,260 @@
1+<script lang="ts">
2+ import type { Favorite } from '../types';
3+
4+ interface Props {
5+ favorite: Favorite;
6+ focused?: boolean;
7+ onSelect?: (fav: Favorite) => void;
8+ onEdit?: (fav: Favorite) => void;
9+ }
10+
11+ let { favorite, focused = false, onSelect, onEdit }: Props = $props();
12+
13+ const getDomain = (url: string): string => {
14+ try {
15+ return new URL(url).hostname.replace('www.', '');
16+ } catch {
17+ return url;
18+ }
19+ };
20+
21+ const getDisplayTitle = (fav: Favorite): string => {
22+ const trimmed = fav.title?.trim();
23+ if (trimmed) return trimmed;
24+ return getDomain(fav.url) || fav.url;
25+ };
26+
27+ const handleSelect = () => {
28+ onSelect?.(favorite);
29+ };
30+
31+ const handleEdit = (e: Event) => {
32+ e.stopPropagation();
33+ onEdit?.(favorite);
34+ };
35+
36+ const handleKeyDown = (e: KeyboardEvent) => {
37+ switch (e.key) {
38+ case 'Enter':
39+ handleSelect();
40+ break;
41+ case 'e':
42+ onEdit?.(favorite);
43+ break;
44+ }
45+ };
46+</script>
47+
48+<div
49+ class="favorite-card"
50+ class:focused
51+ role="button"
52+ tabindex="0"
53+ data-favorite-id={favorite.id}
54+ onclick={handleSelect}
55+ onkeydown={handleKeyDown}
56+>
57+ <div class="card-favicon">
58+ {#if favorite.favicon_url}
59+ <img
60+ src={favorite.favicon_url}
61+ alt=""
62+ class="favicon-img"
63+ onerror={(e) => {
64+ const target = e.target as HTMLImageElement;
65+ target.style.display = 'none';
66+ const fallback = target.nextElementSibling as HTMLElement;
67+ if (fallback) fallback.style.display = 'flex';
68+ }}
69+ />
70+ <div class="favicon-fallback" style="display: none;"></div>
71+ {:else}
72+ <div class="favicon-fallback"></div>
73+ {/if}
74+ {#if favorite.shortcut_key}
75+ <div class="shortcut-badge">{favorite.shortcut_key}</div>
76+ {/if}
77+ </div>
78+
79+ <div class="card-content">
80+ <span class="card-title">{getDisplayTitle(favorite)}</span>
81+ <span class="card-domain">{getDomain(favorite.url)}</span>
82+ </div>
83+
84+ {#if favorite.tags && favorite.tags.length > 0}
85+ <div class="card-tags">
86+ {#each favorite.tags.slice(0, 2) as tag (tag.id)}
87+ <span class="tag-chip" style="--tag-color: {tag.color}">
88+ {tag.name}
89+ </span>
90+ {/each}
91+ {#if favorite.tags.length > 2}
92+ <span class="tag-more">+{favorite.tags.length - 2}</span>
93+ {/if}
94+ </div>
95+ {/if}
96+
97+ <div class="card-actions">
98+ <button
99+ class="action-btn"
100+ type="button"
101+ onclick={handleEdit}
102+ title="Edit (e)"
103+ >
104+
105+ </button>
106+ </div>
107+</div>
108+
109+<style>
110+ .favorite-card {
111+ display: grid;
112+ grid-template-columns: auto 1fr auto auto;
113+ align-items: center;
114+ gap: 0.75rem;
115+ padding: 0.65rem 0.85rem;
116+ border: 1px solid var(--dynamic-border);
117+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
118+ transition: all 100ms ease;
119+ cursor: pointer;
120+ }
121+
122+ .favorite-card:hover,
123+ .favorite-card.focused {
124+ background: color-mix(in srgb, var(--dynamic-surface) 40%, var(--dynamic-bg) 60%);
125+ border-color: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
126+ }
127+
128+ .favorite-card.focused {
129+ outline: 1px solid var(--dynamic-accent, #4ade80);
130+ outline-offset: -1px;
131+ }
132+
133+ .card-favicon {
134+ position: relative;
135+ width: 32px;
136+ height: 32px;
137+ border: 1px solid var(--dynamic-border);
138+ background: var(--dynamic-bg);
139+ display: flex;
140+ align-items: center;
141+ justify-content: center;
142+ flex-shrink: 0;
143+ }
144+
145+ .favicon-img {
146+ width: 18px;
147+ height: 18px;
148+ object-fit: contain;
149+ }
150+
151+ .favicon-fallback {
152+ width: 18px;
153+ height: 18px;
154+ display: flex;
155+ align-items: center;
156+ justify-content: center;
157+ color: var(--dynamic-muted);
158+ }
159+
160+ .favicon-fallback::before {
161+ content: '';
162+ font-size: 0.9rem;
163+ }
164+
165+ .shortcut-badge {
166+ position: absolute;
167+ bottom: -4px;
168+ right: -4px;
169+ width: 14px;
170+ height: 14px;
171+ display: flex;
172+ align-items: center;
173+ justify-content: center;
174+ font-size: 0.55rem;
175+ font-weight: 700;
176+ color: var(--dynamic-bg);
177+ background: var(--dynamic-accent, #4ade80);
178+ border: 1px solid var(--dynamic-bg);
179+ }
180+
181+ .card-content {
182+ display: flex;
183+ flex-direction: column;
184+ gap: 0.15rem;
185+ min-width: 0;
186+ }
187+
188+ .card-title {
189+ font-size: 0.8rem;
190+ font-weight: 500;
191+ color: var(--dynamic-text);
192+ white-space: nowrap;
193+ overflow: hidden;
194+ text-overflow: ellipsis;
195+ }
196+
197+ .card-domain {
198+ font-size: 0.65rem;
199+ color: var(--dynamic-muted);
200+ letter-spacing: 0.06em;
201+ white-space: nowrap;
202+ overflow: hidden;
203+ text-overflow: ellipsis;
204+ }
205+
206+ .card-tags {
207+ display: flex;
208+ align-items: center;
209+ gap: 0.3rem;
210+ }
211+
212+ .tag-chip {
213+ font-size: 0.55rem;
214+ font-weight: 500;
215+ padding: 0.15rem 0.4rem;
216+ background: color-mix(in srgb, var(--tag-color) 20%, transparent);
217+ border: 1px solid var(--tag-color);
218+ color: var(--tag-color);
219+ letter-spacing: 0.05em;
220+ }
221+
222+ .tag-more {
223+ font-size: 0.55rem;
224+ color: var(--dynamic-muted);
225+ padding: 0.15rem 0.3rem;
226+ border: 1px solid var(--dynamic-border);
227+ }
228+
229+ .card-actions {
230+ display: flex;
231+ gap: 0.3rem;
232+ opacity: 0;
233+ transition: opacity 100ms ease;
234+ }
235+
236+ .favorite-card:hover .card-actions,
237+ .favorite-card.focused .card-actions {
238+ opacity: 1;
239+ }
240+
241+ .action-btn {
242+ width: 24px;
243+ height: 24px;
244+ display: flex;
245+ align-items: center;
246+ justify-content: center;
247+ font-size: 0.8rem;
248+ border: 1px solid var(--dynamic-border);
249+ background: transparent;
250+ color: var(--dynamic-muted);
251+ cursor: pointer;
252+ transition: all 100ms ease;
253+ }
254+
255+ .action-btn:hover {
256+ color: var(--dynamic-text);
257+ border-color: var(--dynamic-text);
258+ background: color-mix(in srgb, var(--dynamic-surface) 50%, transparent);
259+ }
260+</style>
A gui/src/pages/homepage/favorites/FavoriteEditor.svelte +479 -0
@@ -0,0 +1,479 @@
1+<script lang="ts">
2+ import type { Favorite, Folder, Tag } from '../types';
3+
4+ interface Props {
5+ favorite: Favorite;
6+ folders: Folder[];
7+ tags: Tag[];
8+ onSave: (updates: Partial<Favorite>) => void;
9+ onDelete: (id: number) => void;
10+ onClose: () => void;
11+ onAssignTag: (favoriteId: number, tagId: number) => void;
12+ onRemoveTag: (favoriteId: number, tagId: number) => void;
13+ }
14+
15+ let {
16+ favorite,
17+ folders,
18+ tags,
19+ onSave,
20+ onDelete,
21+ onClose,
22+ onAssignTag,
23+ onRemoveTag
24+ }: Props = $props();
25+
26+ // Form state
27+ let title = $state(favorite.title || '');
28+ let folderId = $state<number | null>(favorite.folder_id);
29+ let shortcutKey = $state<number | null>(favorite.shortcut_key);
30+ let confirmDelete = $state(false);
31+
32+ // Get favorite's current tags
33+ const favoriteTags = $derived(favorite.tags || []);
34+ const hasTag = (tagId: number) => favoriteTags.some(t => t.id === tagId);
35+
36+ const handleSave = () => {
37+ onSave({
38+ id: favorite.id,
39+ title: title.trim() || null,
40+ folder_id: folderId,
41+ shortcut_key: shortcutKey,
42+ });
43+ onClose();
44+ };
45+
46+ const handleDelete = () => {
47+ if (!confirmDelete) {
48+ confirmDelete = true;
49+ return;
50+ }
51+ onDelete(favorite.id);
52+ onClose();
53+ };
54+
55+ const handleTagClick = (tagId: number) => {
56+ if (hasTag(tagId)) {
57+ onRemoveTag(favorite.id, tagId);
58+ } else {
59+ onAssignTag(favorite.id, tagId);
60+ }
61+ };
62+
63+ const handleKeyDown = (e: KeyboardEvent) => {
64+ switch (e.key) {
65+ case 'Escape':
66+ e.preventDefault();
67+ if (confirmDelete) {
68+ confirmDelete = false;
69+ } else {
70+ onClose();
71+ }
72+ break;
73+ case 'Enter':
74+ if (e.ctrlKey || e.metaKey) {
75+ e.preventDefault();
76+ handleSave();
77+ }
78+ break;
79+ }
80+ };
81+
82+ const handleOverlayClick = (e: Event) => {
83+ if (e.target === e.currentTarget) {
84+ onClose();
85+ }
86+ };
87+</script>
88+
89+<svelte:window onkeydown={handleKeyDown} />
90+
91+<div class="editor-overlay" onclick={handleOverlayClick}>
92+ <div class="editor-modal">
93+ <div class="modal-header">
94+ <span class="modal-icon"></span>
95+ <span class="modal-title">EDIT FAVORITE</span>
96+ <button class="close-btn" type="button" onclick={onClose}>
97+ <kbd>Esc</kbd>
98+ </button>
99+ </div>
100+
101+ <div class="modal-body">
102+ <!-- URL (readonly) -->
103+ <div class="form-group">
104+ <label class="form-label">URL</label>
105+ <div class="url-display">{favorite.url}</div>
106+ </div>
107+
108+ <!-- Title -->
109+ <div class="form-group">
110+ <label class="form-label" for="fav-title">TITLE</label>
111+ <input
112+ id="fav-title"
113+ type="text"
114+ class="form-input"
115+ bind:value={title}
116+ placeholder="Custom title (optional)"
117+ />
118+ </div>
119+
120+ <!-- Folder -->
121+ <div class="form-group">
122+ <label class="form-label" for="fav-folder">FOLDER</label>
123+ <select id="fav-folder" class="form-select" bind:value={folderId}>
124+ <option value={null}>— No folder —</option>
125+ {#each folders as folder (folder.id)}
126+ <option value={folder.id}>
127+ {folder.icon || ''} {folder.name}
128+ </option>
129+ {/each}
130+ </select>
131+ </div>
132+
133+ <!-- Shortcut Key -->
134+ <div class="form-group">
135+ <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>
136+ <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>
137+ <option value={null}>— None —</option>
138+ {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key}
139+ <option value={key}>{key}</option>
140+ {/each}
141+ </select>
142+ <span class="form-hint">Press 1-9 on homepage to open directly</span>
143+ </div>
144+
145+ <!-- Tags -->
146+ <div class="form-group">
147+ <label class="form-label">TAGS</label>
148+ <div class="tag-selector">
149+ {#if tags.length === 0}
150+ <span class="no-tags">No tags available</span>
151+ {:else}
152+ {#each tags as tag (tag.id)}
153+ <button
154+ class="tag-chip"
155+ class:selected={hasTag(tag.id)}
156+ type="button"
157+ onclick={() => handleTagClick(tag.id)}
158+ style="--tag-color: {tag.color}"
159+ >
160+ <span class="tag-dot"></span>
161+ <span class="tag-name">{tag.name}</span>
162+ </button>
163+ {/each}
164+ {/if}
165+ </div>
166+ </div>
167+ </div>
168+
169+ <div class="modal-footer">
170+ {#if confirmDelete}
171+ <div class="confirm-delete">
172+ <span class="confirm-text">Delete this favorite?</span>
173+ <button class="btn btn-cancel" type="button" onclick={() => confirmDelete = false}>
174+ CANCEL
175+ </button>
176+ <button class="btn btn-danger" type="button" onclick={handleDelete}>
177+ DELETE
178+ </button>
179+ </div>
180+ {:else}
181+ <button class="btn btn-danger-outline" type="button" onclick={handleDelete}>
182+ DELETE
183+ </button>
184+ <div class="footer-right">
185+ <button class="btn btn-secondary" type="button" onclick={onClose}>
186+ CANCEL
187+ </button>
188+ <button class="btn btn-primary" type="button" onclick={handleSave}>
189+ SAVE
190+ <kbd>Ctrl+Enter</kbd>
191+ </button>
192+ </div>
193+ {/if}
194+ </div>
195+ </div>
196+</div>
197+
198+<style>
199+ .editor-overlay {
200+ position: fixed;
201+ inset: 0;
202+ z-index: 100;
203+ display: flex;
204+ align-items: center;
205+ justify-content: center;
206+ background: rgb(0 0 0 / 0.75);
207+ backdrop-filter: blur(4px);
208+ animation: fade-in 150ms ease;
209+ }
210+
211+ @keyframes fade-in {
212+ from { opacity: 0; }
213+ }
214+
215+ .editor-modal {
216+ width: 100%;
217+ max-width: 480px;
218+ margin: 1rem;
219+ background: var(--dynamic-surface);
220+ border: 1px solid var(--dynamic-border);
221+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
222+ animation: modal-in 150ms ease;
223+ }
224+
225+ @keyframes modal-in {
226+ from {
227+ opacity: 0;
228+ transform: scale(0.96) translateY(-8px);
229+ }
230+ }
231+
232+ .modal-header {
233+ display: flex;
234+ align-items: center;
235+ gap: 0.6rem;
236+ padding: 0.75rem 1rem;
237+ border-bottom: 1px solid var(--dynamic-border);
238+ background: color-mix(in srgb, var(--dynamic-bg) 80%, transparent);
239+ }
240+
241+ .modal-icon::before {
242+ content: '';
243+ font-size: 0.9rem;
244+ color: var(--dynamic-accent, #4ade80);
245+ }
246+
247+ .modal-title {
248+ flex: 1;
249+ font-size: 0.72rem;
250+ font-weight: 600;
251+ letter-spacing: 0.1em;
252+ color: var(--dynamic-text);
253+ }
254+
255+ .close-btn {
256+ padding: 0.25rem 0.5rem;
257+ background: transparent;
258+ border: 1px solid var(--dynamic-border);
259+ cursor: pointer;
260+ }
261+
262+ .close-btn kbd {
263+ font-size: 0.6rem;
264+ color: var(--dynamic-muted);
265+ }
266+
267+ .close-btn:hover {
268+ border-color: var(--dynamic-text);
269+ }
270+
271+ .close-btn:hover kbd {
272+ color: var(--dynamic-text);
273+ }
274+
275+ .modal-body {
276+ padding: 1rem;
277+ display: flex;
278+ flex-direction: column;
279+ gap: 1rem;
280+ }
281+
282+ .form-group {
283+ display: flex;
284+ flex-direction: column;
285+ gap: 0.35rem;
286+ }
287+
288+ .form-label {
289+ font-size: 0.6rem;
290+ font-weight: 600;
291+ letter-spacing: 0.12em;
292+ color: var(--dynamic-muted);
293+ }
294+
295+ .url-display {
296+ font-size: 0.72rem;
297+ color: var(--dynamic-text);
298+ padding: 0.5rem 0.65rem;
299+ border: 1px solid var(--dynamic-border);
300+ background: var(--dynamic-bg);
301+ white-space: nowrap;
302+ overflow: hidden;
303+ text-overflow: ellipsis;
304+ }
305+
306+ .form-input,
307+ .form-select {
308+ font-family: inherit;
309+ font-size: 0.75rem;
310+ padding: 0.5rem 0.65rem;
311+ border: 1px solid var(--dynamic-border);
312+ background: var(--dynamic-bg);
313+ color: var(--dynamic-text);
314+ outline: none;
315+ transition: border-color 100ms ease;
316+ }
317+
318+ .form-input:focus,
319+ .form-select:focus {
320+ border-color: var(--dynamic-accent, #4ade80);
321+ }
322+
323+ .form-input::placeholder {
324+ color: var(--dynamic-muted);
325+ }
326+
327+ .form-hint {
328+ font-size: 0.6rem;
329+ color: var(--dynamic-muted);
330+ letter-spacing: 0.05em;
331+ }
332+
333+ .tag-selector {
334+ display: flex;
335+ flex-wrap: wrap;
336+ gap: 0.35rem;
337+ }
338+
339+ .no-tags {
340+ font-size: 0.65rem;
341+ color: var(--dynamic-muted);
342+ font-style: italic;
343+ }
344+
345+ .tag-chip {
346+ display: inline-flex;
347+ align-items: center;
348+ gap: 0.35rem;
349+ padding: 0.25rem 0.5rem;
350+ font-size: 0.62rem;
351+ font-weight: 500;
352+ letter-spacing: 0.05em;
353+ border: 1px solid var(--tag-color);
354+ background: transparent;
355+ color: var(--dynamic-text);
356+ cursor: pointer;
357+ transition: all 100ms ease;
358+ }
359+
360+ .tag-chip:hover {
361+ background: color-mix(in srgb, var(--tag-color) 15%, transparent);
362+ }
363+
364+ .tag-chip.selected {
365+ background: color-mix(in srgb, var(--tag-color) 25%, transparent);
366+ border-width: 2px;
367+ padding: calc(0.25rem - 1px) calc(0.5rem - 1px);
368+ }
369+
370+ .tag-dot {
371+ width: 6px;
372+ height: 6px;
373+ background: var(--tag-color);
374+ border-radius: 50%;
375+ }
376+
377+ .tag-name {
378+ text-transform: lowercase;
379+ }
380+
381+ .modal-footer {
382+ display: flex;
383+ align-items: center;
384+ justify-content: space-between;
385+ gap: 0.5rem;
386+ padding: 0.75rem 1rem;
387+ border-top: 1px solid var(--dynamic-border);
388+ background: color-mix(in srgb, var(--dynamic-bg) 60%, transparent);
389+ }
390+
391+ .footer-right {
392+ display: flex;
393+ gap: 0.5rem;
394+ }
395+
396+ .btn {
397+ display: inline-flex;
398+ align-items: center;
399+ gap: 0.5rem;
400+ padding: 0.5rem 0.85rem;
401+ font-size: 0.68rem;
402+ font-weight: 500;
403+ letter-spacing: 0.08em;
404+ border: 1px solid var(--dynamic-border);
405+ cursor: pointer;
406+ transition: all 100ms ease;
407+ }
408+
409+ .btn kbd {
410+ font-size: 0.55rem;
411+ padding: 0.1rem 0.3rem;
412+ border: 1px solid;
413+ opacity: 0.6;
414+ }
415+
416+ .btn-secondary {
417+ background: transparent;
418+ color: var(--dynamic-muted);
419+ }
420+
421+ .btn-secondary:hover {
422+ color: var(--dynamic-text);
423+ border-color: var(--dynamic-text);
424+ }
425+
426+ .btn-primary {
427+ background: var(--dynamic-accent, #4ade80);
428+ color: var(--dynamic-bg);
429+ border-color: var(--dynamic-accent, #4ade80);
430+ }
431+
432+ .btn-primary:hover {
433+ filter: brightness(1.1);
434+ }
435+
436+ .btn-danger-outline {
437+ background: transparent;
438+ color: var(--dynamic-muted);
439+ }
440+
441+ .btn-danger-outline:hover {
442+ color: #ef4444;
443+ border-color: #ef4444;
444+ }
445+
446+ .btn-danger {
447+ background: #b91c1c;
448+ color: #fef2f2;
449+ border-color: #991b1b;
450+ }
451+
452+ .btn-danger:hover {
453+ background: #dc2626;
454+ }
455+
456+ .btn-cancel {
457+ background: transparent;
458+ color: var(--dynamic-muted);
459+ }
460+
461+ .btn-cancel:hover {
462+ color: var(--dynamic-text);
463+ border-color: var(--dynamic-text);
464+ }
465+
466+ .confirm-delete {
467+ display: flex;
468+ align-items: center;
469+ gap: 0.75rem;
470+ width: 100%;
471+ }
472+
473+ .confirm-text {
474+ flex: 1;
475+ font-size: 0.72rem;
476+ color: #ef4444;
477+ font-weight: 500;
478+ }
479+</style>
A gui/src/pages/homepage/favorites/FavoriteGrid.svelte +111 -0
@@ -0,0 +1,111 @@
1+<script lang="ts">
2+ import type { Favorite } from '../types';
3+ import { homepageState } from '../state.svelte';
4+ import FavoriteCard from './FavoriteCard.svelte';
5+
6+ interface Props {
7+ favorites: Favorite[];
8+ onSelectFavorite?: (fav: Favorite) => void;
9+ onEditFavorite?: (fav: Favorite) => void;
10+ }
11+
12+ let { favorites, onSelectFavorite, onEditFavorite }: Props = $props();
13+
14+ let containerRef = $state<HTMLElement | null>(null);
15+
16+ const getFocusedFavorite = () => {
17+ return favorites[homepageState.focusedIndex] ?? null;
18+ };
19+
20+ // Track previous focused index to only scroll on actual navigation
21+ let prevFocusedIndex = $state(-1);
22+
23+ // Scroll focused item into view when focusedIndex changes via keyboard navigation
24+ $effect(() => {
25+ // Skip if panel not active or command palette is open
26+ if (!containerRef || homepageState.activePanel !== 'favorites') return;
27+ if (homepageState.commandPaletteOpen) return;
28+
29+ const currentIndex = homepageState.focusedIndex;
30+
31+ // Only scroll if index actually changed (not on initial render or modal close)
32+ if (prevFocusedIndex === currentIndex) return;
33+ if (prevFocusedIndex === -1) {
34+ // First render - just track, don't scroll
35+ prevFocusedIndex = currentIndex;
36+ return;
37+ }
38+
39+ prevFocusedIndex = currentIndex;
40+
41+ const focusedFav = getFocusedFavorite();
42+ if (!focusedFav) return;
43+
44+ // Find the focused element by favorite ID
45+ const el = containerRef.querySelector(`[data-favorite-id="${focusedFav.id}"]`);
46+ if (el) {
47+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
48+ }
49+ });
50+</script>
51+
52+<div class="favorite-grid" bind:this={containerRef}>
53+ {#if favorites.length === 0}
54+ <div class="empty-state">
55+ <span class="empty-icon"></span>
56+ <span class="empty-text">NO FAVORITES</span>
57+ <span class="empty-hint">Pin sites from history to add them here</span>
58+ </div>
59+ {:else}
60+ {#each favorites as favorite (favorite.id)}
61+ <FavoriteCard
62+ {favorite}
63+ focused={getFocusedFavorite()?.id === favorite.id}
64+ onSelect={onSelectFavorite}
65+ onEdit={onEditFavorite}
66+ />
67+ {/each}
68+ {/if}
69+</div>
70+
71+<style>
72+ .favorite-grid {
73+ display: flex;
74+ flex-direction: column;
75+ gap: 0.5rem;
76+ flex: 1;
77+ min-height: 0;
78+ overflow-y: auto;
79+ }
80+
81+ .empty-state {
82+ flex: 1;
83+ display: flex;
84+ flex-direction: column;
85+ align-items: center;
86+ justify-content: center;
87+ gap: 0.5rem;
88+ padding: 2rem;
89+ color: var(--dynamic-muted);
90+ text-align: center;
91+ border: 1px dashed var(--dynamic-border);
92+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
93+ }
94+
95+ .empty-icon {
96+ font-size: 1.5rem;
97+ opacity: 0.5;
98+ }
99+
100+ .empty-text {
101+ font-size: 0.75rem;
102+ font-weight: 600;
103+ letter-spacing: 0.12em;
104+ }
105+
106+ .empty-hint {
107+ font-size: 0.68rem;
108+ letter-spacing: 0.06em;
109+ opacity: 0.7;
110+ }
111+</style>
A gui/src/pages/homepage/favorites/FolderList.svelte +304 -0
@@ -0,0 +1,304 @@
1+<script lang="ts">
2+ import type { Folder, Favorite } from '../types';
3+
4+ interface Props {
5+ folders: Folder[];
6+ favorites: Favorite[];
7+ selectedFolderId?: number | null;
8+ onSelectFolder?: (id: number | null) => void;
9+ onCreateFolder?: (name: string) => Promise<void>;
10+ }
11+
12+ let {
13+ folders,
14+ favorites,
15+ selectedFolderId = null,
16+ onSelectFolder,
17+ onCreateFolder
18+ }: Props = $props();
19+
20+ // Inline creation state
21+ let isCreating = $state(false);
22+ let newFolderName = $state('');
23+ let inputRef = $state<HTMLInputElement | null>(null);
24+
25+ const startCreating = () => {
26+ isCreating = true;
27+ newFolderName = '';
28+ // Focus input after it renders
29+ setTimeout(() => inputRef?.focus(), 0);
30+ };
31+
32+ const cancelCreating = () => {
33+ isCreating = false;
34+ newFolderName = '';
35+ };
36+
37+ const submitCreate = async () => {
38+ const name = newFolderName.trim();
39+ if (!name || !onCreateFolder) {
40+ cancelCreating();
41+ return;
42+ }
43+ try {
44+ await onCreateFolder(name);
45+ cancelCreating();
46+ } catch (e) {
47+ console.error('Failed to create folder:', e);
48+ }
49+ };
50+
51+ const handleKeydown = (e: KeyboardEvent) => {
52+ if (e.key === 'Enter') {
53+ e.preventDefault();
54+ submitCreate();
55+ } else if (e.key === 'Escape') {
56+ cancelCreating();
57+ }
58+ };
59+
60+ // Count favorites per folder
61+ const getFolderCount = (folderId: number | null): number => {
62+ if (folderId === null) {
63+ return favorites.length;
64+ }
65+ return favorites.filter(f => f.folder_id === folderId).length;
66+ };
67+
68+ // Count unfiled favorites
69+ const unfiledCount = $derived(favorites.filter(f => f.folder_id === null).length);
70+</script>
71+
72+<div class="folder-list">
73+ <div class="folder-header">
74+ <span class="header-label">FOLDERS</span>
75+ {#if isCreating}
76+ <button
77+ class="add-folder-btn cancel"
78+ type="button"
79+ onclick={cancelCreating}
80+ title="Cancel"
81+ >
82+ ×
83+ </button>
84+ {:else}
85+ <button
86+ class="add-folder-btn"
87+ type="button"
88+ onclick={startCreating}
89+ title="Create folder"
90+ >
91+ +
92+ </button>
93+ {/if}
94+ </div>
95+
96+ {#if isCreating}
97+ <div class="create-input-row">
98+ <input
99+ bind:this={inputRef}
100+ bind:value={newFolderName}
101+ onkeydown={handleKeydown}
102+ onblur={cancelCreating}
103+ class="create-input"
104+ type="text"
105+ placeholder="Folder name..."
106+ maxlength="50"
107+ />
108+ </div>
109+ {/if}
110+
111+ <div class="folder-items">
112+ <!-- All favorites -->
113+ <button
114+ class="folder-item"
115+ class:active={selectedFolderId === null}
116+ type="button"
117+ onclick={() => onSelectFolder?.(null)}
118+ >
119+ <span class="folder-icon"></span>
120+ <span class="folder-name">All</span>
121+ <span class="folder-count">{favorites.length}</span>
122+ </button>
123+
124+ <!-- User folders -->
125+ {#each folders as folder (folder.id)}
126+ <button
127+ class="folder-item"
128+ class:active={selectedFolderId === folder.id}
129+ type="button"
130+ onclick={() => onSelectFolder?.(folder.id)}
131+ >
132+ <span class="folder-icon">{folder.icon || ''}</span>
133+ <span class="folder-name">{folder.name}</span>
134+ <span class="folder-count">{getFolderCount(folder.id)}</span>
135+ </button>
136+ {/each}
137+
138+ <!-- Unfiled -->
139+ {#if unfiledCount > 0 && folders.length > 0}
140+ <button
141+ class="folder-item unfiled"
142+ class:active={selectedFolderId === -1}
143+ type="button"
144+ onclick={() => onSelectFolder?.(-1)}
145+ >
146+ <span class="folder-icon"></span>
147+ <span class="folder-name">Unfiled</span>
148+ <span class="folder-count">{unfiledCount}</span>
149+ </button>
150+ {/if}
151+ </div>
152+</div>
153+
154+<style>
155+ .folder-list {
156+ display: flex;
157+ flex-direction: column;
158+ border: 1px solid var(--dynamic-border);
159+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
160+ }
161+
162+ .folder-header {
163+ display: flex;
164+ align-items: center;
165+ justify-content: space-between;
166+ padding: 0.5rem 0.75rem;
167+ border-bottom: 1px solid var(--dynamic-border);
168+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
169+ }
170+
171+ .header-label {
172+ font-size: 0.6rem;
173+ font-weight: 600;
174+ color: var(--dynamic-muted);
175+ letter-spacing: 0.12em;
176+ }
177+
178+ .add-folder-btn {
179+ width: 20px;
180+ height: 20px;
181+ display: flex;
182+ align-items: center;
183+ justify-content: center;
184+ font-size: 0.9rem;
185+ font-weight: 500;
186+ border: 1px solid var(--dynamic-border);
187+ background: transparent;
188+ color: var(--dynamic-muted);
189+ cursor: pointer;
190+ transition: all 100ms ease;
191+ }
192+
193+ .add-folder-btn:hover {
194+ color: var(--dynamic-text);
195+ border-color: var(--dynamic-text);
196+ }
197+
198+ .add-folder-btn.cancel {
199+ color: var(--dynamic-muted);
200+ }
201+
202+ .add-folder-btn.cancel:hover {
203+ color: #ef4444;
204+ border-color: #ef4444;
205+ }
206+
207+ .create-input-row {
208+ padding: 0.4rem 0.5rem;
209+ border-bottom: 1px solid var(--dynamic-border);
210+ background: color-mix(in srgb, var(--dynamic-accent, #4ade80) 8%, var(--dynamic-bg) 92%);
211+ }
212+
213+ .create-input {
214+ width: 100%;
215+ padding: 0.35rem 0.5rem;
216+ font-size: 0.7rem;
217+ font-family: inherit;
218+ color: var(--dynamic-text);
219+ background: var(--dynamic-bg);
220+ border: 1px solid var(--dynamic-accent, #4ade80);
221+ outline: none;
222+ }
223+
224+ .create-input::placeholder {
225+ color: var(--dynamic-muted);
226+ }
227+
228+ .folder-items {
229+ display: flex;
230+ flex-direction: column;
231+ }
232+
233+ .folder-item {
234+ display: flex;
235+ align-items: center;
236+ gap: 0.5rem;
237+ padding: 0.5rem 0.75rem;
238+ text-align: left;
239+ background: transparent;
240+ border: none;
241+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 40%, transparent);
242+ cursor: pointer;
243+ transition: background-color 100ms ease;
244+ }
245+
246+ .folder-item:last-child {
247+ border-bottom: none;
248+ }
249+
250+ .folder-item:hover {
251+ background: color-mix(in srgb, var(--dynamic-surface) 30%, transparent);
252+ }
253+
254+ .folder-item.active {
255+ background: color-mix(in srgb, var(--dynamic-surface) 50%, transparent);
256+ }
257+
258+ .folder-item.active::before {
259+ content: '';
260+ position: absolute;
261+ left: 0;
262+ top: 0;
263+ bottom: 0;
264+ width: 2px;
265+ background: var(--dynamic-accent, #4ade80);
266+ }
267+
268+ .folder-item {
269+ position: relative;
270+ }
271+
272+ .folder-icon {
273+ font-size: 0.8rem;
274+ width: 1.2rem;
275+ text-align: center;
276+ color: var(--dynamic-muted);
277+ }
278+
279+ .folder-item.active .folder-icon {
280+ color: var(--dynamic-text);
281+ }
282+
283+ .folder-name {
284+ flex: 1;
285+ font-size: 0.72rem;
286+ color: var(--dynamic-text);
287+ white-space: nowrap;
288+ overflow: hidden;
289+ text-overflow: ellipsis;
290+ }
291+
292+ .folder-item.unfiled .folder-name {
293+ font-style: italic;
294+ color: var(--dynamic-muted);
295+ }
296+
297+ .folder-count {
298+ font-size: 0.6rem;
299+ padding: 0.1rem 0.35rem;
300+ border: 1px solid var(--dynamic-border);
301+ background: var(--dynamic-bg);
302+ color: var(--dynamic-muted);
303+ }
304+</style>
A gui/src/pages/homepage/favorites/TagCloud.svelte +282 -0
@@ -0,0 +1,282 @@
1+<script lang="ts">
2+ import type { Tag } from '../types';
3+
4+ interface Props {
5+ tags: Tag[];
6+ selectedTagIds?: number[];
7+ onToggleTag?: (id: number) => void;
8+ onCreateTag?: (name: string, color: string) => Promise<void>;
9+ onClearSelection?: () => void;
10+ }
11+
12+ let {
13+ tags,
14+ selectedTagIds = [],
15+ onToggleTag,
16+ onCreateTag,
17+ onClearSelection
18+ }: Props = $props();
19+
20+ // Inline creation state
21+ let isCreating = $state(false);
22+ let newTagName = $state('');
23+ let inputRef = $state<HTMLInputElement | null>(null);
24+
25+ // Predefined tag colors
26+ const tagColors = [
27+ '#6b7280', '#ef4444', '#f59e0b', '#22c55e',
28+ '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'
29+ ];
30+
31+ const getRandomColor = () => tagColors[Math.floor(Math.random() * tagColors.length)] ?? '#6b7280';
32+
33+ const startCreating = () => {
34+ isCreating = true;
35+ newTagName = '';
36+ setTimeout(() => inputRef?.focus(), 0);
37+ };
38+
39+ const cancelCreating = () => {
40+ isCreating = false;
41+ newTagName = '';
42+ };
43+
44+ const submitCreate = async () => {
45+ const name = newTagName.trim();
46+ if (!name || !onCreateTag) {
47+ cancelCreating();
48+ return;
49+ }
50+ try {
51+ await onCreateTag(name, getRandomColor());
52+ cancelCreating();
53+ } catch (e) {
54+ console.error('Failed to create tag:', e);
55+ }
56+ };
57+
58+ const handleKeydown = (e: KeyboardEvent) => {
59+ if (e.key === 'Enter') {
60+ e.preventDefault();
61+ submitCreate();
62+ } else if (e.key === 'Escape') {
63+ cancelCreating();
64+ }
65+ };
66+
67+ const isSelected = (id: number) => selectedTagIds.includes(id);
68+</script>
69+
70+<div class="tag-cloud">
71+ <div class="tag-header">
72+ <span class="header-label">TAGS</span>
73+ <div class="header-actions">
74+ {#if selectedTagIds.length > 0}
75+ <button
76+ class="clear-btn"
77+ type="button"
78+ onclick={() => onClearSelection?.()}
79+ title="Clear selection"
80+ >
81+ CLEAR
82+ </button>
83+ {/if}
84+ {#if isCreating}
85+ <button
86+ class="add-tag-btn cancel"
87+ type="button"
88+ onclick={cancelCreating}
89+ title="Cancel"
90+ >
91+ ×
92+ </button>
93+ {:else}
94+ <button
95+ class="add-tag-btn"
96+ type="button"
97+ onclick={startCreating}
98+ title="Create tag"
99+ >
100+ +
101+ </button>
102+ {/if}
103+ </div>
104+ </div>
105+
106+ {#if isCreating}
107+ <div class="create-input-row">
108+ <input
109+ bind:this={inputRef}
110+ bind:value={newTagName}
111+ onkeydown={handleKeydown}
112+ onblur={cancelCreating}
113+ class="create-input"
114+ type="text"
115+ placeholder="Tag name..."
116+ maxlength="30"
117+ />
118+ </div>
119+ {/if}
120+
121+ <div class="tag-chips">
122+ {#if tags.length === 0}
123+ <span class="no-tags">No tags yet</span>
124+ {:else}
125+ {#each tags as tag (tag.id)}
126+ <button
127+ class="tag-chip"
128+ class:selected={isSelected(tag.id)}
129+ type="button"
130+ onclick={() => onToggleTag?.(tag.id)}
131+ style="--tag-color: {tag.color}"
132+ >
133+ <span class="tag-dot"></span>
134+ <span class="tag-name">{tag.name}</span>
135+ </button>
136+ {/each}
137+ {/if}
138+ </div>
139+</div>
140+
141+<style>
142+ .tag-cloud {
143+ display: flex;
144+ flex-direction: column;
145+ gap: 0.5rem;
146+ padding: 0.6rem 0.75rem;
147+ border: 1px solid var(--dynamic-border);
148+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
149+ }
150+
151+ .tag-header {
152+ display: flex;
153+ align-items: center;
154+ justify-content: space-between;
155+ }
156+
157+ .header-label {
158+ font-size: 0.6rem;
159+ font-weight: 600;
160+ color: var(--dynamic-muted);
161+ letter-spacing: 0.12em;
162+ }
163+
164+ .header-actions {
165+ display: flex;
166+ gap: 0.35rem;
167+ }
168+
169+ .clear-btn {
170+ padding: 0.2rem 0.4rem;
171+ font-size: 0.55rem;
172+ font-weight: 500;
173+ letter-spacing: 0.08em;
174+ border: 1px solid var(--dynamic-border);
175+ background: transparent;
176+ color: var(--dynamic-muted);
177+ cursor: pointer;
178+ transition: all 100ms ease;
179+ }
180+
181+ .clear-btn:hover {
182+ color: var(--dynamic-text);
183+ border-color: var(--dynamic-text);
184+ }
185+
186+ .add-tag-btn {
187+ width: 20px;
188+ height: 20px;
189+ display: flex;
190+ align-items: center;
191+ justify-content: center;
192+ font-size: 0.9rem;
193+ font-weight: 500;
194+ border: 1px solid var(--dynamic-border);
195+ background: transparent;
196+ color: var(--dynamic-muted);
197+ cursor: pointer;
198+ transition: all 100ms ease;
199+ }
200+
201+ .add-tag-btn:hover {
202+ color: var(--dynamic-text);
203+ border-color: var(--dynamic-text);
204+ }
205+
206+ .add-tag-btn.cancel {
207+ color: var(--dynamic-muted);
208+ }
209+
210+ .add-tag-btn.cancel:hover {
211+ color: #ef4444;
212+ border-color: #ef4444;
213+ }
214+
215+ .create-input-row {
216+ margin-bottom: 0.35rem;
217+ }
218+
219+ .create-input {
220+ width: 100%;
221+ padding: 0.35rem 0.5rem;
222+ font-size: 0.68rem;
223+ font-family: inherit;
224+ color: var(--dynamic-text);
225+ background: var(--dynamic-bg);
226+ border: 1px solid var(--dynamic-accent, #4ade80);
227+ outline: none;
228+ }
229+
230+ .create-input::placeholder {
231+ color: var(--dynamic-muted);
232+ }
233+
234+ .tag-chips {
235+ display: flex;
236+ flex-wrap: wrap;
237+ gap: 0.35rem;
238+ }
239+
240+ .no-tags {
241+ font-size: 0.65rem;
242+ color: var(--dynamic-muted);
243+ font-style: italic;
244+ padding: 0.25rem 0;
245+ }
246+
247+ .tag-chip {
248+ display: inline-flex;
249+ align-items: center;
250+ gap: 0.35rem;
251+ padding: 0.25rem 0.5rem;
252+ font-size: 0.62rem;
253+ font-weight: 500;
254+ letter-spacing: 0.05em;
255+ border: 1px solid var(--tag-color);
256+ background: transparent;
257+ color: var(--dynamic-text);
258+ cursor: pointer;
259+ transition: all 100ms ease;
260+ }
261+
262+ .tag-chip:hover {
263+ background: color-mix(in srgb, var(--tag-color) 15%, transparent);
264+ }
265+
266+ .tag-chip.selected {
267+ background: color-mix(in srgb, var(--tag-color) 25%, transparent);
268+ border-width: 2px;
269+ padding: calc(0.25rem - 1px) calc(0.5rem - 1px);
270+ }
271+
272+ .tag-dot {
273+ width: 6px;
274+ height: 6px;
275+ background: var(--tag-color);
276+ border-radius: 50%;
277+ }
278+
279+ .tag-name {
280+ text-transform: lowercase;
281+ }
282+</style>
A gui/src/pages/homepage/favorites/index.ts +6 -0
@@ -0,0 +1,6 @@
1+// Favorites Components - Folders, Tags, Grid, Cards, Editor
2+export { default as FavoriteCard } from './FavoriteCard.svelte';
3+export { default as FavoriteGrid } from './FavoriteGrid.svelte';
4+export { default as FolderList } from './FolderList.svelte';
5+export { default as TagCloud } from './TagCloud.svelte';
6+export { default as FavoriteEditor } from './FavoriteEditor.svelte';
A gui/src/pages/homepage/history/HistoryCleanup.svelte +379 -0
@@ -0,0 +1,379 @@
1+<script lang="ts">
2+ import type { HistoryCleanupRange } from '../types';
3+
4+ interface Props {
5+ onCleanup: (range: HistoryCleanupRange) => void;
6+ onClose: () => void;
7+ }
8+
9+ let { onCleanup, onClose }: Props = $props();
10+
11+ type CleanupOption = {
12+ range: HistoryCleanupRange;
13+ label: string;
14+ shortcut: string;
15+ description: string;
16+ destructive?: boolean;
17+ };
18+
19+ const options: CleanupOption[] = [
20+ { range: 'hour', label: 'LAST HOUR', shortcut: 'D h', description: 'Delete entries from the past 60 minutes' },
21+ { range: 'day', label: 'LAST DAY', shortcut: 'D d', description: 'Delete entries from the past 24 hours' },
22+ { range: 'week', label: 'LAST WEEK', shortcut: 'D w', description: 'Delete entries from the past 7 days' },
23+ { range: 'month', label: 'LAST MONTH', shortcut: 'D m', description: 'Delete entries from the past 30 days' },
24+ { range: 'all', label: 'CLEAR ALL', shortcut: 'D D', description: 'Permanently delete all browsing history', destructive: true },
25+ ];
26+
27+ let selectedIndex = $state(0);
28+ let confirming = $state(false);
29+
30+ const handleKeyDown = (e: KeyboardEvent) => {
31+ switch (e.key) {
32+ case 'Escape':
33+ e.preventDefault();
34+ if (confirming) {
35+ confirming = false;
36+ } else {
37+ onClose();
38+ }
39+ break;
40+ case 'ArrowDown':
41+ case 'j':
42+ e.preventDefault();
43+ selectedIndex = Math.min(selectedIndex + 1, options.length - 1);
44+ break;
45+ case 'ArrowUp':
46+ case 'k':
47+ e.preventDefault();
48+ selectedIndex = Math.max(selectedIndex - 1, 0);
49+ break;
50+ case 'Enter':
51+ e.preventDefault();
52+ handleSelect(options[selectedIndex]!);
53+ break;
54+ }
55+ };
56+
57+ const handleSelect = (option: CleanupOption) => {
58+ if (option.destructive && !confirming) {
59+ confirming = true;
60+ return;
61+ }
62+ onCleanup(option.range);
63+ onClose();
64+ };
65+
66+ const handleOverlayClick = (e: Event) => {
67+ if (e.target === e.currentTarget) {
68+ onClose();
69+ }
70+ };
71+</script>
72+
73+<svelte:window onkeydown={handleKeyDown} />
74+
75+<div class="cleanup-overlay" onclick={handleOverlayClick}>
76+ <div class="cleanup-modal">
77+ <div class="modal-header">
78+ <span class="modal-icon"></span>
79+ <span class="modal-title">CLEAR HISTORY</span>
80+ <button class="close-btn" type="button" onclick={onClose}>
81+ <kbd>Esc</kbd>
82+ </button>
83+ </div>
84+
85+ {#if confirming}
86+ <div class="confirm-panel">
87+ <div class="confirm-icon"></div>
88+ <p class="confirm-message">
89+ This will permanently delete <strong>ALL</strong> browsing history.
90+ This action cannot be undone.
91+ </p>
92+ <div class="confirm-actions">
93+ <button
94+ class="confirm-btn cancel"
95+ type="button"
96+ onclick={() => confirming = false}
97+ >
98+ CANCEL
99+ <kbd>Esc</kbd>
100+ </button>
101+ <button
102+ class="confirm-btn destructive"
103+ type="button"
104+ onclick={() => { onCleanup('all'); onClose(); }}
105+ >
106+ DELETE ALL
107+ <kbd>Enter</kbd>
108+ </button>
109+ </div>
110+ </div>
111+ {:else}
112+ <div class="cleanup-options">
113+ {#each options as option, i (option.range)}
114+ <button
115+ class="cleanup-option"
116+ class:selected={selectedIndex === i}
117+ class:destructive={option.destructive}
118+ type="button"
119+ onclick={() => handleSelect(option)}
120+ onmouseenter={() => selectedIndex = i}
121+ >
122+ <div class="option-main">
123+ <span class="option-label">{option.label}</span>
124+ <span class="option-desc">{option.description}</span>
125+ </div>
126+ <kbd class="option-shortcut">{option.shortcut}</kbd>
127+ </button>
128+ {/each}
129+ </div>
130+ {/if}
131+
132+ <div class="modal-footer">
133+ <span class="hint"><kbd>j</kbd><kbd>k</kbd> navigate</span>
134+ <span class="hint"><kbd>Enter</kbd> select</span>
135+ </div>
136+ </div>
137+</div>
138+
139+<style>
140+ .cleanup-overlay {
141+ position: fixed;
142+ inset: 0;
143+ z-index: 100;
144+ display: flex;
145+ align-items: center;
146+ justify-content: center;
147+ background: rgb(0 0 0 / 0.75);
148+ backdrop-filter: blur(4px);
149+ animation: fade-in 150ms ease;
150+ }
151+
152+ @keyframes fade-in {
153+ from { opacity: 0; }
154+ }
155+
156+ .cleanup-modal {
157+ width: 100%;
158+ max-width: 420px;
159+ margin: 1rem;
160+ background: var(--dynamic-surface);
161+ border: 1px solid var(--dynamic-border);
162+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
163+ animation: modal-in 150ms ease;
164+ }
165+
166+ @keyframes modal-in {
167+ from {
168+ opacity: 0;
169+ transform: scale(0.96) translateY(-8px);
170+ }
171+ }
172+
173+ .modal-header {
174+ display: flex;
175+ align-items: center;
176+ gap: 0.6rem;
177+ padding: 0.75rem 1rem;
178+ border-bottom: 1px solid var(--dynamic-border);
179+ background: color-mix(in srgb, var(--dynamic-bg) 80%, transparent);
180+ }
181+
182+ .modal-icon::before {
183+ content: '';
184+ font-size: 0.9rem;
185+ color: #fbbf24;
186+ }
187+
188+ .modal-title {
189+ flex: 1;
190+ font-size: 0.72rem;
191+ font-weight: 600;
192+ letter-spacing: 0.1em;
193+ color: var(--dynamic-text);
194+ }
195+
196+ .close-btn {
197+ padding: 0.25rem 0.5rem;
198+ background: transparent;
199+ border: 1px solid var(--dynamic-border);
200+ cursor: pointer;
201+ }
202+
203+ .close-btn kbd {
204+ font-size: 0.6rem;
205+ color: var(--dynamic-muted);
206+ }
207+
208+ .close-btn:hover {
209+ border-color: var(--dynamic-text);
210+ }
211+
212+ .close-btn:hover kbd {
213+ color: var(--dynamic-text);
214+ }
215+
216+ .cleanup-options {
217+ display: flex;
218+ flex-direction: column;
219+ }
220+
221+ .cleanup-option {
222+ display: flex;
223+ align-items: center;
224+ justify-content: space-between;
225+ gap: 1rem;
226+ padding: 0.85rem 1rem;
227+ text-align: left;
228+ background: transparent;
229+ border: none;
230+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
231+ cursor: pointer;
232+ transition: background-color 100ms ease;
233+ }
234+
235+ .cleanup-option:last-child {
236+ border-bottom: none;
237+ }
238+
239+ .cleanup-option:hover,
240+ .cleanup-option.selected {
241+ background: color-mix(in srgb, var(--dynamic-surface) 80%, var(--dynamic-bg) 20%);
242+ }
243+
244+ .cleanup-option.selected {
245+ outline: 1px solid var(--dynamic-accent, #4ade80);
246+ outline-offset: -1px;
247+ }
248+
249+ .cleanup-option.destructive .option-label {
250+ color: #ef4444;
251+ }
252+
253+ .option-main {
254+ display: flex;
255+ flex-direction: column;
256+ gap: 0.2rem;
257+ }
258+
259+ .option-label {
260+ font-size: 0.75rem;
261+ font-weight: 600;
262+ letter-spacing: 0.08em;
263+ color: var(--dynamic-text);
264+ }
265+
266+ .option-desc {
267+ font-size: 0.68rem;
268+ color: var(--dynamic-muted);
269+ letter-spacing: 0.04em;
270+ }
271+
272+ .option-shortcut {
273+ padding: 0.2rem 0.45rem;
274+ font-size: 0.58rem;
275+ letter-spacing: 0.05em;
276+ border: 1px solid var(--dynamic-border);
277+ background: var(--dynamic-bg);
278+ color: var(--dynamic-muted);
279+ }
280+
281+ .confirm-panel {
282+ padding: 1.5rem 1rem;
283+ display: flex;
284+ flex-direction: column;
285+ align-items: center;
286+ gap: 1rem;
287+ text-align: center;
288+ }
289+
290+ .confirm-icon::before {
291+ content: '';
292+ font-size: 2rem;
293+ color: #ef4444;
294+ }
295+
296+ .confirm-message {
297+ margin: 0;
298+ font-size: 0.8rem;
299+ line-height: 1.5;
300+ color: var(--dynamic-text);
301+ }
302+
303+ .confirm-message strong {
304+ color: #ef4444;
305+ }
306+
307+ .confirm-actions {
308+ display: flex;
309+ gap: 0.5rem;
310+ width: 100%;
311+ margin-top: 0.5rem;
312+ }
313+
314+ .confirm-btn {
315+ flex: 1;
316+ display: flex;
317+ align-items: center;
318+ justify-content: center;
319+ gap: 0.5rem;
320+ padding: 0.6rem 1rem;
321+ font-size: 0.7rem;
322+ font-weight: 500;
323+ letter-spacing: 0.08em;
324+ border: 1px solid var(--dynamic-border);
325+ cursor: pointer;
326+ transition: all 100ms ease;
327+ }
328+
329+ .confirm-btn kbd {
330+ font-size: 0.55rem;
331+ padding: 0.1rem 0.3rem;
332+ border: 1px solid;
333+ opacity: 0.7;
334+ }
335+
336+ .confirm-btn.cancel {
337+ background: transparent;
338+ color: var(--dynamic-muted);
339+ }
340+
341+ .confirm-btn.cancel:hover {
342+ color: var(--dynamic-text);
343+ border-color: var(--dynamic-text);
344+ }
345+
346+ .confirm-btn.destructive {
347+ background: #b91c1c;
348+ color: #fef2f2;
349+ border-color: #991b1b;
350+ }
351+
352+ .confirm-btn.destructive:hover {
353+ background: #dc2626;
354+ }
355+
356+ .modal-footer {
357+ display: flex;
358+ align-items: center;
359+ justify-content: center;
360+ gap: 1rem;
361+ padding: 0.6rem 1rem;
362+ border-top: 1px solid var(--dynamic-border);
363+ background: color-mix(in srgb, var(--dynamic-bg) 60%, transparent);
364+ }
365+
366+ .hint {
367+ font-size: 0.6rem;
368+ color: var(--dynamic-muted);
369+ letter-spacing: 0.05em;
370+ }
371+
372+ .hint kbd {
373+ font-size: 0.55rem;
374+ padding: 0.1rem 0.25rem;
375+ margin-right: 0.25rem;
376+ border: 1px solid var(--dynamic-border);
377+ background: var(--dynamic-bg);
378+ }
379+</style>
A gui/src/pages/homepage/history/HistoryFilters.svelte +151 -0
@@ -0,0 +1,151 @@
1+<script lang="ts">
2+ import type { DomainStat } from '../types';
3+
4+ interface Props {
5+ domains?: DomainStat[];
6+ selectedDomain?: string | null;
7+ onSelectDomain?: (domain: string | null) => void;
8+ onOpenCleanup?: () => void;
9+ }
10+
11+ let {
12+ domains = [],
13+ selectedDomain = null,
14+ onSelectDomain,
15+ onOpenCleanup
16+ }: Props = $props();
17+
18+ const topDomains = $derived(domains.slice(0, 5));
19+</script>
20+
21+<div class="history-filters">
22+ <div class="filter-section">
23+ <span class="filter-label">FILTER</span>
24+ <div class="filter-chips">
25+ <button
26+ class="filter-chip"
27+ class:active={selectedDomain === null}
28+ type="button"
29+ onclick={() => onSelectDomain?.(null)}
30+ >
31+ ALL
32+ </button>
33+ {#each topDomains as domain (domain.domain)}
34+ <button
35+ class="filter-chip"
36+ class:active={selectedDomain === domain.domain}
37+ type="button"
38+ onclick={() => onSelectDomain?.(domain.domain)}
39+ title="{domain.page_count} pages, {domain.total_visits} visits"
40+ >
41+ {domain.domain.replace('www.', '').slice(0, 12)}
42+ </button>
43+ {/each}
44+ </div>
45+ </div>
46+
47+ <div class="filter-actions">
48+ <button
49+ class="cleanup-btn"
50+ type="button"
51+ onclick={() => onOpenCleanup?.()}
52+ title="Clear history (D)"
53+ >
54+ <span class="cleanup-icon"></span>
55+ <span>CLEAR</span>
56+ </button>
57+ </div>
58+</div>
59+
60+<style>
61+ .history-filters {
62+ display: flex;
63+ align-items: center;
64+ justify-content: space-between;
65+ gap: 1rem;
66+ padding: 0.5rem 1rem;
67+ }
68+
69+ .filter-section {
70+ display: flex;
71+ align-items: center;
72+ gap: 0.6rem;
73+ flex: 1;
74+ min-width: 0;
75+ }
76+
77+ .filter-label {
78+ font-size: 0.6rem;
79+ font-weight: 600;
80+ color: var(--dynamic-muted);
81+ letter-spacing: 0.12em;
82+ flex-shrink: 0;
83+ }
84+
85+ .filter-chips {
86+ display: flex;
87+ gap: 0.35rem;
88+ overflow-x: auto;
89+ scrollbar-width: none;
90+ }
91+
92+ .filter-chips::-webkit-scrollbar {
93+ display: none;
94+ }
95+
96+ .filter-chip {
97+ padding: 0.3rem 0.55rem;
98+ font-size: 0.6rem;
99+ font-weight: 500;
100+ letter-spacing: 0.08em;
101+ color: var(--dynamic-muted);
102+ background: transparent;
103+ border: 1px solid var(--dynamic-border);
104+ cursor: pointer;
105+ transition: all 100ms ease;
106+ white-space: nowrap;
107+ flex-shrink: 0;
108+ }
109+
110+ .filter-chip:hover {
111+ color: var(--dynamic-text);
112+ border-color: var(--dynamic-text);
113+ }
114+
115+ .filter-chip.active {
116+ color: var(--dynamic-primary-foreground, var(--dynamic-bg));
117+ background: var(--dynamic-accent);
118+ border-color: var(--dynamic-accent);
119+ }
120+
121+ .filter-actions {
122+ display: flex;
123+ gap: 0.35rem;
124+ flex-shrink: 0;
125+ }
126+
127+ .cleanup-btn {
128+ display: flex;
129+ align-items: center;
130+ gap: 0.4rem;
131+ padding: 0.35rem 0.6rem;
132+ font-size: 0.6rem;
133+ font-weight: 500;
134+ letter-spacing: 0.08em;
135+ color: var(--dynamic-muted);
136+ background: transparent;
137+ border: 1px solid var(--dynamic-border);
138+ cursor: pointer;
139+ transition: all 100ms ease;
140+ }
141+
142+ .cleanup-btn:hover {
143+ color: #ef4444;
144+ border-color: #ef4444;
145+ }
146+
147+ .cleanup-icon::before {
148+ content: '';
149+ font-size: 0.75rem;
150+ }
151+</style>
A gui/src/pages/homepage/history/HistoryItem.svelte +264 -0
@@ -0,0 +1,264 @@
1+<script lang="ts">
2+ import type { HistoryEntry } from '../types';
3+
4+ interface Props {
5+ entry: HistoryEntry;
6+ focused?: boolean;
7+ onSelect?: (entry: HistoryEntry) => void;
8+ onDelete?: (entry: HistoryEntry) => void;
9+ }
10+
11+ let { entry, focused = false, onSelect, onDelete }: Props = $props();
12+
13+ let deleting = $state(false);
14+
15+ const getDomain = (url: string): string => {
16+ try {
17+ return new URL(url).hostname;
18+ } catch {
19+ return url;
20+ }
21+ };
22+
23+ const getDisplayTitle = (item: HistoryEntry): string => {
24+ const trimmed = item.title?.trim();
25+ if (trimmed) return trimmed;
26+ return getDomain(item.url) || item.url;
27+ };
28+
29+ const formatTime = (timestamp: string): string => {
30+ try {
31+ const date = new Date(timestamp);
32+ const now = new Date();
33+ const diff = now.getTime() - date.getTime();
34+
35+ const minutes = Math.floor(diff / (1000 * 60));
36+ const hours = Math.floor(diff / (1000 * 60 * 60));
37+
38+ if (minutes < 1) return 'now';
39+ if (minutes < 60) return `${minutes}m`;
40+ if (hours < 24) return `${hours}h`;
41+
42+ return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
43+ } catch {
44+ return '';
45+ }
46+ };
47+
48+ const handleSelect = () => {
49+ onSelect?.(entry);
50+ };
51+
52+ const handleDelete = (e: Event) => {
53+ e.stopPropagation();
54+ deleting = true;
55+ onDelete?.(entry);
56+ };
57+
58+ const handleKeyDown = (e: KeyboardEvent) => {
59+ if (e.key === 'Enter') {
60+ handleSelect();
61+ } else if (e.key === 'd' || e.key === 'x') {
62+ handleDelete(e);
63+ }
64+ };
65+</script>
66+
67+<div
68+ class="history-item"
69+ class:focused
70+ class:deleting
71+ role="button"
72+ tabindex="0"
73+ data-entry-id={entry.id}
74+ onclick={handleSelect}
75+ onkeydown={handleKeyDown}
76+>
77+ <div class="item-favicon">
78+ {#if entry.favicon_url}
79+ <img
80+ src={entry.favicon_url}
81+ alt=""
82+ class="favicon-img"
83+ onerror={(e) => {
84+ const target = e.target as HTMLImageElement;
85+ target.style.display = 'none';
86+ const fallback = target.nextElementSibling as HTMLElement;
87+ if (fallback) fallback.style.display = 'flex';
88+ }}
89+ />
90+ <div class="favicon-fallback" style="display: none;"></div>
91+ {:else}
92+ <div class="favicon-fallback"></div>
93+ {/if}
94+ </div>
95+
96+ <div class="item-content">
97+ <div class="item-row">
98+ <span class="item-title">{getDisplayTitle(entry)}</span>
99+ <span class="item-time">{formatTime(entry.last_visited)}</span>
100+ </div>
101+ <div class="item-url">{entry.url}</div>
102+ </div>
103+
104+ <div class="item-actions">
105+ {#if entry.visit_count > 1}
106+ <span class="visit-count">{entry.visit_count}x</span>
107+ {/if}
108+ <button
109+ class="action-btn delete-btn"
110+ type="button"
111+ onclick={handleDelete}
112+ title="Delete entry (d)"
113+ >
114+ <span aria-hidden="true">×</span>
115+ </button>
116+ </div>
117+</div>
118+
119+<style>
120+ .history-item {
121+ display: grid;
122+ grid-template-columns: auto 1fr auto;
123+ align-items: center;
124+ gap: 0.75rem;
125+ padding: 0.55rem 0.85rem;
126+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
127+ transition: background-color 100ms ease;
128+ cursor: pointer;
129+ }
130+
131+ .history-item:last-child {
132+ border-bottom: none;
133+ }
134+
135+ .history-item:hover,
136+ .history-item.focused {
137+ background: color-mix(in srgb, var(--dynamic-surface) 40%, transparent);
138+ }
139+
140+ .history-item.focused {
141+ outline: 1px solid var(--dynamic-accent, #4ade80);
142+ outline-offset: -1px;
143+ }
144+
145+ .history-item.deleting {
146+ opacity: 0.3;
147+ pointer-events: none;
148+ }
149+
150+ .item-favicon {
151+ width: 28px;
152+ height: 28px;
153+ border: 1px solid var(--dynamic-border);
154+ background: var(--dynamic-bg);
155+ display: flex;
156+ align-items: center;
157+ justify-content: center;
158+ flex-shrink: 0;
159+ }
160+
161+ .favicon-img {
162+ width: 16px;
163+ height: 16px;
164+ object-fit: contain;
165+ }
166+
167+ .favicon-fallback {
168+ width: 16px;
169+ height: 16px;
170+ display: flex;
171+ align-items: center;
172+ justify-content: center;
173+ color: var(--dynamic-muted);
174+ }
175+
176+ .favicon-fallback::before {
177+ content: '';
178+ font-size: 0.75rem;
179+ }
180+
181+ .item-content {
182+ display: flex;
183+ flex-direction: column;
184+ gap: 0.15rem;
185+ min-width: 0;
186+ }
187+
188+ .item-row {
189+ display: flex;
190+ align-items: center;
191+ justify-content: space-between;
192+ gap: 0.75rem;
193+ }
194+
195+ .item-title {
196+ font-size: 0.8rem;
197+ font-weight: 500;
198+ color: var(--dynamic-text);
199+ white-space: nowrap;
200+ overflow: hidden;
201+ text-overflow: ellipsis;
202+ }
203+
204+ .item-time {
205+ font-size: 0.65rem;
206+ color: var(--dynamic-muted);
207+ text-transform: uppercase;
208+ letter-spacing: 0.08em;
209+ white-space: nowrap;
210+ flex-shrink: 0;
211+ }
212+
213+ .item-url {
214+ font-size: 0.68rem;
215+ color: var(--dynamic-muted);
216+ white-space: nowrap;
217+ overflow: hidden;
218+ text-overflow: ellipsis;
219+ }
220+
221+ .item-actions {
222+ display: flex;
223+ align-items: center;
224+ gap: 0.5rem;
225+ }
226+
227+ .visit-count {
228+ font-size: 0.6rem;
229+ color: var(--dynamic-muted);
230+ padding: 0.15rem 0.35rem;
231+ border: 1px solid var(--dynamic-border);
232+ background: var(--dynamic-bg);
233+ }
234+
235+ .action-btn {
236+ width: 24px;
237+ height: 24px;
238+ display: flex;
239+ align-items: center;
240+ justify-content: center;
241+ border: 1px solid var(--dynamic-border);
242+ background: transparent;
243+ color: var(--dynamic-muted);
244+ cursor: pointer;
245+ transition: all 100ms ease;
246+ opacity: 0;
247+ }
248+
249+ .history-item:hover .action-btn,
250+ .history-item.focused .action-btn {
251+ opacity: 1;
252+ }
253+
254+ .action-btn:hover {
255+ color: var(--dynamic-text);
256+ border-color: var(--dynamic-text);
257+ background: color-mix(in srgb, var(--dynamic-surface) 50%, transparent);
258+ }
259+
260+ .delete-btn:hover {
261+ color: #ef4444;
262+ border-color: #ef4444;
263+ }
264+</style>
A gui/src/pages/homepage/history/HistorySearch.svelte +171 -0
@@ -0,0 +1,171 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { homepageState } from '../state.svelte';
4+ import { handleSearchInput } from '../keyboard';
5+
6+ interface Props {
7+ placeholder?: string;
8+ autofocus?: boolean;
9+ }
10+
11+ let { placeholder = 'Search history...', autofocus = false }: Props = $props();
12+
13+ let inputRef = $state<HTMLInputElement | null>(null);
14+
15+ // Auto-focus on mount if requested
16+ onMount(() => {
17+ if (autofocus && inputRef) {
18+ inputRef.focus();
19+ }
20+ });
21+
22+ const handleInput = (e: Event) => {
23+ const target = e.target as HTMLInputElement;
24+ handleSearchInput(target.value);
25+ };
26+
27+ const handleKeyDown = (e: KeyboardEvent) => {
28+ switch (e.key) {
29+ case 'Escape':
30+ e.preventDefault();
31+ e.stopPropagation();
32+ if (homepageState.historySearchQuery) {
33+ // First Esc: clear search
34+ homepageState.setHistorySearchQuery('');
35+ } else {
36+ // Second Esc (or if no query): blur input
37+ inputRef?.blur();
38+ }
39+ break;
40+ case 'ArrowDown':
41+ case 'j':
42+ if (e.key === 'j' && !e.ctrlKey) break; // Only Ctrl+j or ArrowDown
43+ e.preventDefault();
44+ homepageState.focusNext();
45+ inputRef?.blur();
46+ break;
47+ case 'ArrowUp':
48+ case 'k':
49+ if (e.key === 'k' && !e.ctrlKey) break; // Only Ctrl+k or ArrowUp
50+ e.preventDefault();
51+ // Don't move focus - already at top
52+ break;
53+ }
54+ };
55+
56+ // Focus input when focusedIndex becomes -1 (user pressed up from first item)
57+ $effect(() => {
58+ if (homepageState.focusedIndex === -1 && homepageState.activePanel === 'history') {
59+ inputRef?.focus();
60+ }
61+ });
62+
63+ const clearSearch = () => {
64+ homepageState.setHistorySearchQuery('');
65+ inputRef?.focus();
66+ };
67+</script>
68+
69+<div class="history-search">
70+ <div class="search-icon"></div>
71+ <input
72+ bind:this={inputRef}
73+ type="text"
74+ class="search-input"
75+ {placeholder}
76+ value={homepageState.historySearchQuery}
77+ oninput={handleInput}
78+ onkeydown={handleKeyDown}
79+ />
80+ {#if homepageState.historySearching}
81+ <div class="search-spinner"></div>
82+ {:else if homepageState.historySearchQuery}
83+ <button
84+ class="clear-btn"
85+ type="button"
86+ onclick={clearSearch}
87+ title="Clear search (Esc)"
88+ >
89+ ×
90+ </button>
91+ {:else}
92+ <kbd class="search-hint">/</kbd>
93+ {/if}
94+</div>
95+
96+<style>
97+ .history-search {
98+ display: flex;
99+ align-items: center;
100+ gap: 0.6rem;
101+ padding: 0.5rem 0.75rem;
102+ border: 1px solid var(--dynamic-border);
103+ background: var(--dynamic-bg);
104+ transition: border-color 150ms ease;
105+ }
106+
107+ .history-search:focus-within {
108+ border-color: var(--dynamic-accent, #4ade80);
109+ }
110+
111+ .search-icon::before {
112+ content: '';
113+ font-size: 0.85rem;
114+ color: var(--dynamic-muted);
115+ }
116+
117+ .search-input {
118+ flex: 1;
119+ border: none;
120+ background: transparent;
121+ color: var(--dynamic-text);
122+ font-family: inherit;
123+ font-size: 0.8rem;
124+ outline: none;
125+ }
126+
127+ .search-input::placeholder {
128+ color: var(--dynamic-muted);
129+ letter-spacing: 0.05em;
130+ }
131+
132+ .search-spinner {
133+ width: 14px;
134+ height: 14px;
135+ border: 2px solid var(--dynamic-border);
136+ border-top-color: var(--dynamic-accent, #4ade80);
137+ border-radius: 50%;
138+ animation: spin 0.8s linear infinite;
139+ }
140+
141+ @keyframes spin {
142+ to { transform: rotate(360deg); }
143+ }
144+
145+ .clear-btn {
146+ width: 20px;
147+ height: 20px;
148+ display: flex;
149+ align-items: center;
150+ justify-content: center;
151+ border: 1px solid var(--dynamic-border);
152+ background: transparent;
153+ color: var(--dynamic-muted);
154+ font-size: 0.9rem;
155+ cursor: pointer;
156+ transition: all 100ms ease;
157+ }
158+
159+ .clear-btn:hover {
160+ color: var(--dynamic-text);
161+ border-color: var(--dynamic-text);
162+ }
163+
164+ .search-hint {
165+ font-size: 0.6rem;
166+ padding: 0.15rem 0.35rem;
167+ border: 1px solid var(--dynamic-border);
168+ background: color-mix(in srgb, var(--dynamic-surface) 30%, transparent);
169+ color: var(--dynamic-muted);
170+ }
171+</style>
A gui/src/pages/homepage/history/HistoryTimeline.svelte +220 -0
@@ -0,0 +1,220 @@
1+<script lang="ts">
2+ import type { TimelineGroup, HistoryEntry } from '../types';
3+ import { homepageState } from '../state.svelte';
4+ import HistoryItem from './HistoryItem.svelte';
5+
6+ interface Props {
7+ groups: TimelineGroup[];
8+ onSelectEntry?: (entry: HistoryEntry) => void;
9+ onDeleteEntry?: (entry: HistoryEntry) => void;
10+ onLoadMore?: () => void;
11+ }
12+
13+ let { groups, onSelectEntry, onDeleteEntry, onLoadMore }: Props = $props();
14+
15+ let containerRef = $state<HTMLElement | null>(null);
16+ let sentinelRef = $state<HTMLElement | null>(null);
17+
18+ // Flatten groups to get global index for keyboard navigation
19+ const flatEntries = $derived(() => {
20+ const entries: HistoryEntry[] = [];
21+ for (const group of groups) {
22+ entries.push(...group.entries);
23+ }
24+ return entries;
25+ });
26+
27+ // Track which entry is focused based on global focusedIndex
28+ const getFocusedEntry = () => {
29+ const entries = flatEntries();
30+ return entries[homepageState.focusedIndex] ?? null;
31+ };
32+
33+ // Track previous focused index to only scroll on actual navigation
34+ let prevFocusedIndex = $state(-1);
35+
36+ // Scroll focused item into view when focusedIndex changes via keyboard navigation
37+ $effect(() => {
38+ // Skip if panel not active or command palette is open
39+ if (!containerRef || homepageState.activePanel !== 'history') return;
40+ if (homepageState.commandPaletteOpen) return;
41+
42+ const currentIndex = homepageState.focusedIndex;
43+
44+ // Only scroll if index actually changed (not on initial render or modal close)
45+ if (prevFocusedIndex === currentIndex) return;
46+ if (prevFocusedIndex === -1) {
47+ // First render - just track, don't scroll
48+ prevFocusedIndex = currentIndex;
49+ return;
50+ }
51+
52+ prevFocusedIndex = currentIndex;
53+
54+ const focusedEntry = getFocusedEntry();
55+ if (!focusedEntry) return;
56+
57+ // Find the focused element by entry ID
58+ const el = containerRef.querySelector(`[data-entry-id="${focusedEntry.id}"]`);
59+ if (el) {
60+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
61+ }
62+ });
63+
64+ // Setup infinite scroll observer
65+ $effect(() => {
66+ if (!sentinelRef || !onLoadMore) return;
67+
68+ const observer = new IntersectionObserver(
69+ (entries) => {
70+ const entry = entries[0];
71+ if (entry?.isIntersecting && homepageState.hasMoreHistory && !homepageState.historyLoading) {
72+ onLoadMore();
73+ }
74+ },
75+ { threshold: 0.1, rootMargin: '100px' }
76+ );
77+
78+ observer.observe(sentinelRef);
79+
80+ return () => observer.disconnect();
81+ });
82+</script>
83+
84+<div class="history-timeline" bind:this={containerRef}>
85+ {#if groups.length === 0}
86+ <div class="empty-state">
87+ <span class="empty-icon"></span>
88+ <span class="empty-text">NO HISTORY RECORDED</span>
89+ <span class="empty-hint">Start browsing to see your history here</span>
90+ </div>
91+ {:else}
92+ {#each groups as group (group.date)}
93+ <div class="timeline-group">
94+ <div class="group-header">
95+ <span class="group-label">{group.label}</span>
96+ <span class="group-count">{group.entries.length}</span>
97+ </div>
98+ <div class="group-entries">
99+ {#each group.entries as entry (entry.id)}
100+ <HistoryItem
101+ {entry}
102+ focused={getFocusedEntry()?.id === entry.id}
103+ onSelect={onSelectEntry}
104+ onDelete={onDeleteEntry}
105+ />
106+ {/each}
107+ </div>
108+ </div>
109+ {/each}
110+
111+ {#if homepageState.hasMoreHistory}
112+ <div class="load-more-sentinel" bind:this={sentinelRef}>
113+ {#if homepageState.historyLoading}
114+ <span class="loading-text">LOADING...</span>
115+ {/if}
116+ </div>
117+ {/if}
118+ {/if}
119+</div>
120+
121+<style>
122+ .history-timeline {
123+ display: flex;
124+ flex-direction: column;
125+ flex: 1;
126+ min-height: 0;
127+ overflow-y: auto;
128+ }
129+
130+ .empty-state {
131+ flex: 1;
132+ display: flex;
133+ flex-direction: column;
134+ align-items: center;
135+ justify-content: center;
136+ gap: 0.5rem;
137+ padding: 2rem;
138+ color: var(--dynamic-muted);
139+ text-align: center;
140+ }
141+
142+ .empty-icon {
143+ font-size: 1.5rem;
144+ opacity: 0.5;
145+ }
146+
147+ .empty-text {
148+ font-size: 0.75rem;
149+ font-weight: 600;
150+ letter-spacing: 0.12em;
151+ }
152+
153+ .empty-hint {
154+ font-size: 0.7rem;
155+ letter-spacing: 0.08em;
156+ opacity: 0.7;
157+ }
158+
159+ .timeline-group {
160+ border-bottom: 1px solid var(--dynamic-border);
161+ }
162+
163+ .timeline-group:last-child {
164+ border-bottom: none;
165+ }
166+
167+ .group-header {
168+ display: flex;
169+ align-items: center;
170+ justify-content: space-between;
171+ gap: 0.75rem;
172+ padding: 0.5rem 0.85rem;
173+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
174+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
175+ position: sticky;
176+ top: 0;
177+ z-index: 1;
178+ }
179+
180+ .group-label {
181+ font-size: 0.68rem;
182+ font-weight: 600;
183+ text-transform: uppercase;
184+ letter-spacing: 0.12em;
185+ color: var(--dynamic-text);
186+ }
187+
188+ .group-count {
189+ font-size: 0.6rem;
190+ padding: 0.15rem 0.4rem;
191+ border: 1px solid var(--dynamic-border);
192+ background: var(--dynamic-bg);
193+ color: var(--dynamic-muted);
194+ }
195+
196+ .group-entries {
197+ display: flex;
198+ flex-direction: column;
199+ }
200+
201+ .load-more-sentinel {
202+ padding: 0.75rem;
203+ display: flex;
204+ align-items: center;
205+ justify-content: center;
206+ min-height: 40px;
207+ }
208+
209+ .loading-text {
210+ font-size: 0.65rem;
211+ color: var(--dynamic-muted);
212+ letter-spacing: 0.12em;
213+ animation: pulse 1.5s ease-in-out infinite;
214+ }
215+
216+ @keyframes pulse {
217+ 0%, 100% { opacity: 0.4; }
218+ 50% { opacity: 1; }
219+ }
220+</style>
A gui/src/pages/homepage/history/index.ts +6 -0
@@ -0,0 +1,6 @@
1+// History Components - Timeline, Search, Filters, Cleanup
2+export { default as HistoryItem } from './HistoryItem.svelte';
3+export { default as HistoryTimeline } from './HistoryTimeline.svelte';
4+export { default as HistorySearch } from './HistorySearch.svelte';
5+export { default as HistoryFilters } from './HistoryFilters.svelte';
6+export { default as HistoryCleanup } from './HistoryCleanup.svelte';
A gui/src/pages/homepage/keyboard.ts +744 -0
@@ -0,0 +1,744 @@
1+// ═══════════════════════════════════════════════════════════════════════════════
2+// Homepage Keyboard Navigation (Telescope-style)
3+// Vim-inspired navigation with fuzzy filtering and quick shortcuts
4+// ═══════════════════════════════════════════════════════════════════════════════
5+
6+import { homepageState } from './state.svelte';
7+import {
8+ navigateTo,
9+ deleteHistoryEntry,
10+ deleteHistoryByRange,
11+ clearAllHistory,
12+ deleteHistoryByDomain,
13+ getFavoriteByShortcut,
14+ searchHistoryFTS,
15+} from './messaging';
16+import type { HistoryEntry, Favorite, HistoryCleanupRange } from './types';
17+
18+// ═══════════════════════════════════════════════════════════════════════════════
19+// TYPES
20+// ═══════════════════════════════════════════════════════════════════════════════
21+
22+interface KeyBinding {
23+ key: string;
24+ ctrl?: boolean;
25+ shift?: boolean;
26+ alt?: boolean;
27+ meta?: boolean;
28+ description: string;
29+ action: () => void | Promise<void>;
30+ context?: 'global' | 'history' | 'favorites' | 'command-palette' | 'cleanup';
31+}
32+
33+// ═══════════════════════════════════════════════════════════════════════════════
34+// STATE
35+// ═══════════════════════════════════════════════════════════════════════════════
36+
37+let pendingPrefix: string | null = null;
38+let prefixTimeout: ReturnType<typeof setTimeout> | null = null;
39+const PREFIX_TIMEOUT_MS = 1000;
40+
41+// ═══════════════════════════════════════════════════════════════════════════════
42+// HELPER FUNCTIONS
43+// ═══════════════════════════════════════════════════════════════════════════════
44+
45+function getDomain(url: string): string {
46+ try {
47+ return new URL(url).hostname;
48+ } catch {
49+ return url;
50+ }
51+}
52+
53+function getCurrentItem(): HistoryEntry | Favorite | null {
54+ const list = homepageState.currentList;
55+ const idx = homepageState.focusedIndex;
56+ return list[idx] ?? null;
57+}
58+
59+function clearPendingPrefix(): void {
60+ pendingPrefix = null;
61+ homepageState.setPendingKeyPrefix(null);
62+ if (prefixTimeout) {
63+ clearTimeout(prefixTimeout);
64+ prefixTimeout = null;
65+ }
66+}
67+
68+function setPendingPrefix(prefix: string): void {
69+ pendingPrefix = prefix;
70+ homepageState.setPendingKeyPrefix(prefix);
71+ if (prefixTimeout) {
72+ clearTimeout(prefixTimeout);
73+ }
74+ prefixTimeout = setTimeout(clearPendingPrefix, PREFIX_TIMEOUT_MS);
75+}
76+
77+// ═══════════════════════════════════════════════════════════════════════════════
78+// ACTIONS
79+// ═══════════════════════════════════════════════════════════════════════════════
80+
81+async function openCurrentItem(): Promise<void> {
82+ const item = getCurrentItem();
83+ if (item && 'url' in item) {
84+ navigateTo(item.url);
85+ }
86+}
87+
88+async function deleteCurrentHistoryEntry(): Promise<void> {
89+ if (homepageState.activePanel !== 'history') return;
90+
91+ const item = getCurrentItem() as HistoryEntry | null;
92+ if (!item) return;
93+
94+ await deleteHistoryEntry(item.id);
95+
96+ // Adjust focus if needed
97+ const newLength = homepageState.currentListLength;
98+ if (homepageState.focusedIndex >= newLength) {
99+ homepageState.setFocusedIndex(Math.max(0, newLength - 1));
100+ }
101+}
102+
103+async function handleCleanupShortcut(range: HistoryCleanupRange): Promise<void> {
104+ if (homepageState.activePanel !== 'history') return;
105+
106+ const messages: Record<HistoryCleanupRange, string> = {
107+ hour: 'Clear history from the last hour?',
108+ day: 'Clear history from the last day?',
109+ week: 'Clear history from the last week?',
110+ month: 'Clear history from the last month?',
111+ all: 'Clear ALL history? This cannot be undone.',
112+ };
113+
114+ homepageState.showConfirm(messages[range], async () => {
115+ if (range === 'all') {
116+ await clearAllHistory();
117+ } else {
118+ await deleteHistoryByRange(range);
119+ }
120+ });
121+}
122+
123+async function clearCurrentDomain(): Promise<void> {
124+ if (homepageState.activePanel !== 'history') return;
125+
126+ const item = getCurrentItem() as HistoryEntry | null;
127+ if (!item) return;
128+
129+ const domain = getDomain(item.url);
130+ homepageState.showConfirm(
131+ `Clear all history for ${domain}?`,
132+ async () => {
133+ await deleteHistoryByDomain(domain);
134+ }
135+ );
136+}
137+
138+async function handleQuickShortcut(key: number): Promise<void> {
139+ const favorite = await getFavoriteByShortcut(key);
140+ if (favorite) {
141+ navigateTo(favorite.url);
142+ }
143+}
144+
145+// ═══════════════════════════════════════════════════════════════════════════════
146+// KEY BINDINGS
147+// ═══════════════════════════════════════════════════════════════════════════════
148+
149+const globalBindings: KeyBinding[] = [
150+ // Command palette
151+ {
152+ key: 'p',
153+ ctrl: true,
154+ description: 'Open command palette',
155+ action: () => homepageState.openCommandPalette(),
156+ },
157+ // Note: "/" may require Shift on some keyboard layouts (e.g., AZERTY)
158+ // We handle it specially in handleKeyDown to ignore Shift state
159+ {
160+ key: '/',
161+ description: 'Open command palette / search',
162+ action: () => homepageState.openCommandPalette(),
163+ },
164+
165+ // Quick navigation (1-9)
166+ ...Array.from({ length: 9 }, (_, i) => ({
167+ key: String(i + 1),
168+ description: `Navigate to favorite shortcut ${i + 1}`,
169+ action: () => handleQuickShortcut(i + 1),
170+ })),
171+
172+ // Panel navigation (g prefix)
173+ {
174+ key: 'h',
175+ description: 'Go to History panel',
176+ action: () => {
177+ if (pendingPrefix === 'g') {
178+ homepageState.setActivePanel('history');
179+ clearPendingPrefix();
180+ }
181+ },
182+ context: 'global',
183+ },
184+ {
185+ key: 'f',
186+ description: 'Go to Favorites panel',
187+ action: () => {
188+ if (pendingPrefix === 'g') {
189+ homepageState.setActivePanel('favorites');
190+ clearPendingPrefix();
191+ }
192+ },
193+ context: 'global',
194+ },
195+ {
196+ key: 'a',
197+ description: 'Go to Analytics panel',
198+ action: () => {
199+ if (pendingPrefix === 'g') {
200+ homepageState.setActivePanel('analytics');
201+ clearPendingPrefix();
202+ }
203+ },
204+ context: 'global',
205+ },
206+
207+ // Escape
208+ {
209+ key: 'Escape',
210+ description: 'Close modals / clear search',
211+ action: () => {
212+ clearPendingPrefix();
213+ if (homepageState.commandPaletteOpen) {
214+ homepageState.closeCommandPalette();
215+ } else if (homepageState.confirmModalOpen) {
216+ homepageState.cancelConfirm();
217+ } else if (homepageState.cleanupModalOpen) {
218+ homepageState.closeCleanupModal();
219+ } else if (homepageState.historySearchQuery) {
220+ homepageState.setHistorySearchQuery('');
221+ }
222+ },
223+ },
224+
225+ // Tab - cycle through panels
226+ {
227+ key: 'Tab',
228+ description: 'Next panel',
229+ action: () => {
230+ const panels = ['history', 'favorites', 'analytics'] as const;
231+ const currentIdx = panels.indexOf(homepageState.activePanel);
232+ const nextIdx = (currentIdx + 1) % panels.length;
233+ const nextPanel = panels[nextIdx];
234+ if (nextPanel) {
235+ homepageState.setActivePanel(nextPanel);
236+ }
237+ },
238+ },
239+ {
240+ key: 'Tab',
241+ shift: true,
242+ description: 'Previous panel',
243+ action: () => {
244+ const panels = ['history', 'favorites', 'analytics'] as const;
245+ const currentIdx = panels.indexOf(homepageState.activePanel);
246+ const prevIdx = (currentIdx - 1 + panels.length) % panels.length;
247+ const prevPanel = panels[prevIdx];
248+ if (prevPanel) {
249+ homepageState.setActivePanel(prevPanel);
250+ }
251+ },
252+ },
253+];
254+
255+const listBindings: KeyBinding[] = [
256+ // Navigation
257+ {
258+ key: 'j',
259+ description: 'Move down',
260+ action: () => homepageState.focusNext(),
261+ },
262+ {
263+ key: 'ArrowDown',
264+ description: 'Move down',
265+ action: () => homepageState.focusNext(),
266+ },
267+ {
268+ key: 'k',
269+ description: 'Move up',
270+ action: () => homepageState.focusPrev(),
271+ },
272+ {
273+ key: 'ArrowUp',
274+ description: 'Move up',
275+ action: () => homepageState.focusPrev(),
276+ },
277+ {
278+ key: 'n',
279+ ctrl: true,
280+ description: 'Move down',
281+ action: () => homepageState.focusNext(),
282+ },
283+ {
284+ key: 'p',
285+ ctrl: true,
286+ description: 'Move up',
287+ action: () => homepageState.focusPrev(),
288+ },
289+ {
290+ key: 'g',
291+ description: 'Go to first / prefix for panel nav',
292+ action: () => {
293+ if (pendingPrefix === 'g') {
294+ homepageState.focusFirst();
295+ clearPendingPrefix();
296+ } else {
297+ setPendingPrefix('g');
298+ }
299+ },
300+ },
301+ {
302+ key: 'G',
303+ shift: true,
304+ description: 'Go to last',
305+ action: () => homepageState.focusLast(),
306+ },
307+
308+ // Selection
309+ {
310+ key: 'Enter',
311+ description: 'Open selected item',
312+ action: openCurrentItem,
313+ },
314+ {
315+ key: 'o',
316+ description: 'Open selected item',
317+ action: openCurrentItem,
318+ },
319+];
320+
321+const historyBindings: KeyBinding[] = [
322+ // Delete
323+ {
324+ key: 'd',
325+ description: 'Delete entry / prefix for cleanup',
326+ action: () => {
327+ if (pendingPrefix === 'D') {
328+ // D d - clear last day
329+ handleCleanupShortcut('day');
330+ clearPendingPrefix();
331+ } else {
332+ deleteCurrentHistoryEntry();
333+ }
334+ },
335+ context: 'history',
336+ },
337+ {
338+ key: 'x',
339+ description: 'Delete entry',
340+ action: deleteCurrentHistoryEntry,
341+ context: 'history',
342+ },
343+
344+ // Cleanup shortcuts (D prefix)
345+ {
346+ key: 'D',
347+ shift: true,
348+ description: 'Cleanup prefix / Clear ALL',
349+ action: () => {
350+ if (pendingPrefix === 'D') {
351+ // D D - clear all
352+ handleCleanupShortcut('all');
353+ clearPendingPrefix();
354+ } else {
355+ setPendingPrefix('D');
356+ }
357+ },
358+ context: 'history',
359+ },
360+ {
361+ key: 'h',
362+ description: 'Clear last hour',
363+ action: () => {
364+ if (pendingPrefix === 'D') {
365+ handleCleanupShortcut('hour');
366+ clearPendingPrefix();
367+ }
368+ },
369+ context: 'history',
370+ },
371+ {
372+ key: 'w',
373+ description: 'Clear last week',
374+ action: () => {
375+ if (pendingPrefix === 'D') {
376+ handleCleanupShortcut('week');
377+ clearPendingPrefix();
378+ }
379+ },
380+ context: 'history',
381+ },
382+ {
383+ key: 'm',
384+ description: 'Clear last month',
385+ action: () => {
386+ if (pendingPrefix === 'D') {
387+ handleCleanupShortcut('month');
388+ clearPendingPrefix();
389+ }
390+ },
391+ context: 'history',
392+ },
393+ {
394+ key: '@',
395+ shift: true,
396+ description: 'Clear current domain',
397+ action: () => {
398+ if (pendingPrefix === 'D') {
399+ clearCurrentDomain();
400+ clearPendingPrefix();
401+ }
402+ },
403+ context: 'history',
404+ },
405+];
406+
407+const commandPaletteBindings: KeyBinding[] = [
408+ {
409+ key: 'j',
410+ ctrl: true,
411+ description: 'Move down',
412+ action: () => homepageState.focusNext(),
413+ context: 'command-palette',
414+ },
415+ {
416+ key: 'n',
417+ ctrl: true,
418+ description: 'Move down',
419+ action: () => homepageState.focusNext(),
420+ context: 'command-palette',
421+ },
422+ {
423+ key: 'ArrowDown',
424+ description: 'Move down',
425+ action: () => homepageState.focusNext(),
426+ context: 'command-palette',
427+ },
428+ {
429+ key: 'k',
430+ ctrl: true,
431+ description: 'Move up',
432+ action: () => homepageState.focusPrev(),
433+ context: 'command-palette',
434+ },
435+ {
436+ key: 'p',
437+ ctrl: true,
438+ description: 'Move up',
439+ action: () => homepageState.focusPrev(),
440+ context: 'command-palette',
441+ },
442+ {
443+ key: 'ArrowUp',
444+ description: 'Move up',
445+ action: () => homepageState.focusPrev(),
446+ context: 'command-palette',
447+ },
448+ {
449+ key: 'Enter',
450+ description: 'Execute selected command',
451+ action: () => {
452+ // This will be handled by the command palette component
453+ },
454+ context: 'command-palette',
455+ },
456+];
457+
458+// ═══════════════════════════════════════════════════════════════════════════════
459+// KEY HANDLER
460+// ═══════════════════════════════════════════════════════════════════════════════
461+
462+function matchBinding(e: KeyboardEvent, binding: KeyBinding): boolean {
463+ if (binding.key !== e.key) return false;
464+ if (!!binding.ctrl !== e.ctrlKey) return false;
465+ if (!!binding.shift !== e.shiftKey) return false;
466+ if (!!binding.alt !== e.altKey) return false;
467+ if (!!binding.meta !== e.metaKey) return false;
468+ return true;
469+}
470+
471+function getActiveBindings(): KeyBinding[] {
472+ const bindings: KeyBinding[] = [...globalBindings];
473+
474+ if (homepageState.commandPaletteOpen) {
475+ bindings.push(...commandPaletteBindings);
476+ } else {
477+ bindings.push(...listBindings);
478+
479+ switch (homepageState.activePanel) {
480+ case 'history':
481+ bindings.push(...historyBindings);
482+ break;
483+ case 'favorites':
484+ // Add favorites-specific bindings here
485+ break;
486+ }
487+ }
488+
489+ return bindings;
490+}
491+
492+export function handleKeyDown(e: KeyboardEvent): void {
493+ // Skip if focused on input/textarea (except for navigation keys)
494+ const target = e.target as HTMLElement;
495+ const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
496+
497+ // Special handling for "/" - opens command palette from anywhere
498+ // Ignore modifier keys since "/" may require Shift on some layouts (AZERTY)
499+ if (e.key === '/') {
500+ e.preventDefault();
501+ e.stopPropagation();
502+ if (isInput) {
503+ (target as HTMLInputElement).blur();
504+ }
505+ homepageState.openCommandPalette();
506+ return;
507+ }
508+
509+ if (isInput) {
510+ // Allow specific keys when in input
511+ const allowedInInput = ['Escape', 'ArrowUp', 'ArrowDown', 'Enter'];
512+ const ctrlAllowed = ['j', 'k', 'n', 'p'];
513+
514+ if (!allowedInInput.includes(e.key) && !(e.ctrlKey && ctrlAllowed.includes(e.key))) {
515+ return;
516+ }
517+ }
518+
519+ const bindings = getActiveBindings();
520+
521+ for (const binding of bindings) {
522+ if (matchBinding(e, binding)) {
523+ // Check context
524+ if (binding.context) {
525+ switch (binding.context) {
526+ case 'history':
527+ if (homepageState.activePanel !== 'history') continue;
528+ break;
529+ case 'favorites':
530+ if (homepageState.activePanel !== 'favorites') continue;
531+ break;
532+ case 'command-palette':
533+ if (!homepageState.commandPaletteOpen) continue;
534+ break;
535+ }
536+ }
537+
538+ e.preventDefault();
539+ e.stopPropagation();
540+ binding.action();
541+ return;
542+ }
543+ }
544+
545+ // Handle 'g' prefix standalone
546+ if (e.key === 'g' && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && !isInput) {
547+ if (!pendingPrefix) {
548+ e.preventDefault();
549+ setPendingPrefix('g');
550+ }
551+ }
552+}
553+
554+// ═══════════════════════════════════════════════════════════════════════════════
555+// TIERED SEARCH (Client-side first, then FTS)
556+// ═══════════════════════════════════════════════════════════════════════════════
557+
558+let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
559+const SEARCH_DEBOUNCE_MS = 400;
560+const MIN_FTS_QUERY_LENGTH = 3;
561+
562+/**
563+ * Fuzzy match score calculator.
564+ * Supports words in any order (fzf-style).
565+ * Returns score > 0 if match, 0 if no match. Higher = better.
566+ */
567+function fuzzyScore(text: string, query: string): number {
568+ const lowerText = text.toLowerCase();
569+ const lowerQuery = query.toLowerCase();
570+
571+ // Split query into words for "any order" matching
572+ const queryWords = lowerQuery.split(/\s+/).filter(w => w.length > 0);
573+ if (queryWords.length === 0) return 0;
574+
575+ let totalScore = 0;
576+
577+ for (const word of queryWords) {
578+ // Exact substring match (highest score)
579+ const exactIdx = lowerText.indexOf(word);
580+ if (exactIdx !== -1) {
581+ // Bonus for word boundary match (start of text or after space/punctuation)
582+ const atBoundary = exactIdx === 0 || /[\s\-_./]/.test(lowerText[exactIdx - 1] || '');
583+ totalScore += 100 + (atBoundary ? 50 : 0) + (word.length * 10);
584+ continue;
585+ }
586+
587+ // Fuzzy: chars in sequence (lower score)
588+ let qi = 0;
589+ let lastMatchIdx = -1;
590+ let consecutiveBonus = 0;
591+
592+ for (let i = 0; i < lowerText.length && qi < word.length; i++) {
593+ if (lowerText[i] === word[qi]) {
594+ // Bonus for consecutive matches
595+ if (lastMatchIdx === i - 1) consecutiveBonus += 5;
596+ lastMatchIdx = i;
597+ qi++;
598+ }
599+ }
600+
601+ if (qi === word.length) {
602+ // All chars found in order
603+ totalScore += 30 + consecutiveBonus;
604+ } else {
605+ // This query word not found - no match
606+ return 0;
607+ }
608+ }
609+
610+ return totalScore;
611+}
612+
613+interface ScoredEntry {
614+ entry: HistoryEntry;
615+ score: number;
616+}
617+
618+/**
619+ * Filter history entries client-side using fuzzy matching.
620+ * Combines title + URL so words can match across both fields.
621+ * E.g. "google search" matches title "Google" + URL containing "search"
622+ */
623+function filterHistoryLocal(query: string): HistoryEntry[] {
624+ const q = query.trim();
625+ if (!q) return [];
626+
627+ const scored: ScoredEntry[] = [];
628+
629+ for (const entry of homepageState.history) {
630+ const title = entry.title || '';
631+ const url = entry.url;
632+
633+ // Combine title and URL for cross-field word matching
634+ // Title gets priority (appears first), URL stripped of protocol
635+ const urlClean = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
636+ const combined = `${title} ${urlClean}`;
637+
638+ const score = fuzzyScore(combined, q);
639+
640+ // Boost score if title matches directly (more relevant)
641+ const titleBonus = fuzzyScore(title, q) > 0 ? 50 : 0;
642+
643+ if (score > 0) {
644+ scored.push({ entry, score: score + titleBonus });
645+ }
646+ }
647+
648+ // Sort by score descending, limit results
649+ scored.sort((a, b) => b.score - a.score);
650+ return scored.slice(0, 50).map(s => s.entry);
651+}
652+
653+export function handleSearchInput(query: string): void {
654+ homepageState.setHistorySearchQuery(query);
655+
656+ if (searchDebounceTimer) {
657+ clearTimeout(searchDebounceTimer);
658+ searchDebounceTimer = null;
659+ }
660+
661+ const trimmed = query.trim();
662+ if (!trimmed) {
663+ homepageState.setHistorySearchResults([]);
664+ return;
665+ }
666+
667+ // Tier 1: Instant client-side fuzzy filter on cached history
668+ const localResults = filterHistoryLocal(trimmed);
669+ homepageState.setHistorySearchResults(localResults);
670+
671+ // Tier 2: If local returns 0 results AND query is long enough, try FTS
672+ // Also try FTS after debounce if query is substantial (deeper search)
673+ if (trimmed.length >= MIN_FTS_QUERY_LENGTH) {
674+ searchDebounceTimer = setTimeout(() => {
675+ // Only call FTS if local results are sparse
676+ if (localResults.length < 5) {
677+ searchHistoryFTS(trimmed);
678+ }
679+ }, SEARCH_DEBOUNCE_MS);
680+ }
681+}
682+
683+// ═══════════════════════════════════════════════════════════════════════════════
684+// KEYBOARD HINTS FOR UI
685+// ═══════════════════════════════════════════════════════════════════════════════
686+
687+export interface KeyboardHint {
688+ keys: string[];
689+ description: string;
690+}
691+
692+export function getContextualHints(): KeyboardHint[] {
693+ const hints: KeyboardHint[] = [];
694+
695+ if (homepageState.commandPaletteOpen) {
696+ hints.push(
697+ { keys: ['Ctrl+j', '↓'], description: 'Move down' },
698+ { keys: ['Ctrl+k', '↑'], description: 'Move up' },
699+ { keys: ['Enter'], description: 'Execute' },
700+ { keys: ['Escape'], description: 'Close' }
701+ );
702+ } else {
703+ hints.push(
704+ { keys: ['j', '↓'], description: 'Move down' },
705+ { keys: ['k', '↑'], description: 'Move up' },
706+ { keys: ['Enter', 'o'], description: 'Open' },
707+ { keys: ['/'], description: 'Search' }
708+ );
709+
710+ if (homepageState.activePanel === 'history') {
711+ hints.push(
712+ { keys: ['d', 'x'], description: 'Delete' },
713+ { keys: ['D h'], description: 'Clear hour' },
714+ { keys: ['D d'], description: 'Clear day' },
715+ { keys: ['D D'], description: 'Clear all' }
716+ );
717+ }
718+
719+ hints.push(
720+ { keys: ['g h'], description: 'History' },
721+ { keys: ['g f'], description: 'Favorites' },
722+ { keys: ['g a'], description: 'Analytics' },
723+ { keys: ['1-9'], description: 'Quick nav' }
724+ );
725+ }
726+
727+ return hints;
728+}
729+
730+// ═══════════════════════════════════════════════════════════════════════════════
731+// INITIALIZATION
732+// ═══════════════════════════════════════════════════════════════════════════════
733+
734+export function initializeKeyboard(): () => void {
735+ document.addEventListener('keydown', handleKeyDown);
736+
737+ return () => {
738+ document.removeEventListener('keydown', handleKeyDown);
739+ clearPendingPrefix();
740+ if (searchDebounceTimer) {
741+ clearTimeout(searchDebounceTimer);
742+ }
743+ };
744+}
A gui/src/pages/homepage/messaging.ts +515 -0
@@ -0,0 +1,515 @@
1+// ═══════════════════════════════════════════════════════════════════════════════
2+// Homepage WebKit Bridge
3+// Communication layer between frontend and Go backend via WebKit message handlers
4+// ═══════════════════════════════════════════════════════════════════════════════
5+
6+import type {
7+ MessageType,
8+ HistoryEntry,
9+ Folder,
10+ Tag,
11+ Favorite,
12+ DomainStat,
13+ Analytics,
14+ HistoryCleanupRange,
15+ FolderCreateRequest,
16+ FolderUpdateRequest,
17+ TagCreateRequest,
18+ TagUpdateRequest,
19+} from './types';
20+import { homepageState } from './state.svelte';
21+
22+// ═══════════════════════════════════════════════════════════════════════════════
23+// REQUEST TRACKING
24+// ═══════════════════════════════════════════════════════════════════════════════
25+
26+interface PendingRequest<T = unknown> {
27+ resolve: (value: T) => void;
28+ reject: (error: Error) => void;
29+ timeout: ReturnType<typeof setTimeout>;
30+}
31+
32+const pendingRequests = new Map<string, PendingRequest>();
33+const REQUEST_TIMEOUT_MS = 10_000;
34+
35+let callbacksInitialized = false;
36+
37+// ═══════════════════════════════════════════════════════════════════════════════
38+// WEBKIT BRIDGE UTILITIES
39+// ═══════════════════════════════════════════════════════════════════════════════
40+
41+function getWebKitBridge(): { postMessage: (msg: string) => void } | null {
42+ const bridge = (window as any).webkit?.messageHandlers?.dumber;
43+ if (bridge && typeof bridge.postMessage === 'function') {
44+ return bridge;
45+ }
46+ return null;
47+}
48+
49+function generateRequestId(type: string): string {
50+ return `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
51+}
52+
53+// ═══════════════════════════════════════════════════════════════════════════════
54+// CALLBACK INITIALIZATION
55+// ═══════════════════════════════════════════════════════════════════════════════
56+
57+function initializeCallbacks(): void {
58+ if (callbacksInitialized) return;
59+
60+ // Generic response handler
61+ (window as any).__dumber_homepage_response = (
62+ requestId: string,
63+ success: boolean,
64+ data: unknown,
65+ error?: string
66+ ) => {
67+ const request = pendingRequests.get(requestId);
68+ if (!request) return;
69+
70+ clearTimeout(request.timeout);
71+ pendingRequests.delete(requestId);
72+
73+ if (success) {
74+ request.resolve(data);
75+ } else {
76+ request.reject(new Error(error || 'Unknown error'));
77+ }
78+ };
79+
80+ // Legacy callbacks for backward compatibility
81+ (window as any).__dumber_history_timeline = (data: unknown[], requestId?: string) => {
82+ handleLegacyCallback(requestId, data);
83+ };
84+
85+ (window as any).__dumber_folders = (data: unknown[], requestId?: string) => {
86+ handleLegacyCallback(requestId, data);
87+ };
88+
89+ (window as any).__dumber_tags = (data: unknown[], requestId?: string) => {
90+ handleLegacyCallback(requestId, data);
91+ };
92+
93+ (window as any).__dumber_favorites = (data: unknown[], requestId?: string) => {
94+ handleLegacyCallback(requestId, data);
95+ };
96+
97+ (window as any).__dumber_analytics = (data: unknown, requestId?: string) => {
98+ handleLegacyCallback(requestId, data);
99+ };
100+
101+ (window as any).__dumber_domain_stats = (data: unknown[], requestId?: string) => {
102+ handleLegacyCallback(requestId, data);
103+ };
104+
105+ (window as any).__dumber_history_search_results = (data: unknown[], requestId?: string) => {
106+ handleLegacyCallback(requestId, data);
107+ };
108+
109+ (window as any).__dumber_history_deleted = (requestId?: string) => {
110+ handleLegacyCallback(requestId, { success: true });
111+ };
112+
113+ (window as any).__dumber_history_cleared = (requestId?: string) => {
114+ handleLegacyCallback(requestId, { success: true });
115+ };
116+
117+ (window as any).__dumber_error = (error: string, requestId?: string) => {
118+ const request = pendingRequests.get(requestId || 'default');
119+ if (request) {
120+ clearTimeout(request.timeout);
121+ pendingRequests.delete(requestId || 'default');
122+ request.reject(new Error(error));
123+ }
124+ };
125+
126+ // Listen for favorites changes from omnibox (e.g., when user toggles favorite)
127+ document.addEventListener('dumber:favorites-changed', () => {
128+ console.log('[homepage] Favorites changed event received, refreshing...');
129+ fetchFavorites();
130+ });
131+
132+ callbacksInitialized = true;
133+}
134+
135+function handleLegacyCallback(requestId: string | undefined, data: unknown): void {
136+ const request = pendingRequests.get(requestId || 'default');
137+ if (request) {
138+ clearTimeout(request.timeout);
139+ pendingRequests.delete(requestId || 'default');
140+ request.resolve(data);
141+ }
142+}
143+
144+// ═══════════════════════════════════════════════════════════════════════════════
145+// CORE SEND FUNCTION
146+// ═══════════════════════════════════════════════════════════════════════════════
147+
148+async function sendMessage<T = unknown>(
149+ type: MessageType,
150+ payload?: Record<string, unknown>
151+): Promise<T> {
152+ initializeCallbacks();
153+
154+ const bridge = getWebKitBridge();
155+ if (!bridge) {
156+ throw new Error('WebKit message handler not available');
157+ }
158+
159+ const requestId = generateRequestId(type);
160+
161+ return new Promise<T>((resolve, reject) => {
162+ const timeout = setTimeout(() => {
163+ pendingRequests.delete(requestId);
164+ reject(new Error(`Request timeout: ${type}`));
165+ }, REQUEST_TIMEOUT_MS);
166+
167+ pendingRequests.set(requestId, { resolve: resolve as any, reject, timeout });
168+
169+ try {
170+ bridge.postMessage(JSON.stringify({
171+ type,
172+ requestId,
173+ ...payload,
174+ }));
175+ } catch (error) {
176+ clearTimeout(timeout);
177+ pendingRequests.delete(requestId);
178+ reject(error);
179+ }
180+ });
181+}
182+
183+// ═══════════════════════════════════════════════════════════════════════════════
184+// HISTORY API
185+// ═══════════════════════════════════════════════════════════════════════════════
186+
187+export async function fetchHistoryTimeline(
188+ limit: number = 50,
189+ offset: number = 0
190+): Promise<HistoryEntry[]> {
191+ homepageState.setHistoryLoading(true);
192+ try {
193+ const data = await sendMessage<HistoryEntry[]>('history_timeline', {
194+ limit,
195+ offset,
196+ });
197+ const entries = Array.isArray(data) ? data : [];
198+
199+ if (offset === 0) {
200+ homepageState.setHistory(entries);
201+ } else {
202+ homepageState.appendHistory(entries);
203+ }
204+
205+ homepageState.setHistoryOffset(offset + entries.length);
206+ homepageState.setHasMoreHistory(entries.length === limit);
207+
208+ return entries;
209+ } catch (error) {
210+ console.error('[messaging] fetchHistoryTimeline error:', error);
211+ if (offset === 0) {
212+ homepageState.setHistory([]);
213+ }
214+ homepageState.setHasMoreHistory(false);
215+ return [];
216+ } finally {
217+ homepageState.setHistoryLoading(false);
218+ }
219+}
220+
221+export async function searchHistoryFTS(query: string): Promise<HistoryEntry[]> {
222+ if (!query.trim()) {
223+ homepageState.setHistorySearchResults([]);
224+ return [];
225+ }
226+
227+ homepageState.setHistorySearching(true);
228+ try {
229+ const data = await sendMessage<HistoryEntry[]>('history_search_fts', {
230+ query,
231+ limit: 100,
232+ });
233+ const results = Array.isArray(data) ? data : [];
234+ homepageState.setHistorySearchResults(results);
235+ return results;
236+ } catch (error) {
237+ console.error('[messaging] searchHistoryFTS error:', error);
238+ homepageState.setHistorySearchResults([]);
239+ return [];
240+ } finally {
241+ homepageState.setHistorySearching(false);
242+ }
243+}
244+
245+export async function deleteHistoryEntry(id: number): Promise<void> {
246+ try {
247+ await sendMessage('history_delete_entry', { id });
248+ homepageState.deleteHistoryEntry(id);
249+ } catch (error) {
250+ console.error('[messaging] deleteHistoryEntry error:', error);
251+ throw error;
252+ }
253+}
254+
255+export async function deleteHistoryByRange(range: HistoryCleanupRange): Promise<void> {
256+ try {
257+ await sendMessage('history_delete_range', { range });
258+ // Refresh history after deletion
259+ await fetchHistoryTimeline();
260+ } catch (error) {
261+ console.error('[messaging] deleteHistoryByRange error:', error);
262+ throw error;
263+ }
264+}
265+
266+export async function clearAllHistory(): Promise<void> {
267+ try {
268+ await sendMessage('history_clear_all');
269+ homepageState.clearHistory();
270+ } catch (error) {
271+ console.error('[messaging] clearAllHistory error:', error);
272+ throw error;
273+ }
274+}
275+
276+export async function deleteHistoryByDomain(domain: string): Promise<void> {
277+ try {
278+ await sendMessage('history_delete_domain', { domain });
279+ // Refresh history after deletion
280+ await fetchHistoryTimeline();
281+ } catch (error) {
282+ console.error('[messaging] deleteHistoryByDomain error:', error);
283+ throw error;
284+ }
285+}
286+
287+// ═══════════════════════════════════════════════════════════════════════════════
288+// FOLDERS API
289+// ═══════════════════════════════════════════════════════════════════════════════
290+
291+export async function fetchFolders(): Promise<Folder[]> {
292+ try {
293+ const data = await sendMessage<Folder[]>('folder_list');
294+ const folders = Array.isArray(data) ? data : [];
295+ homepageState.setFolders(folders);
296+ return folders;
297+ } catch (error) {
298+ console.error('[messaging] fetchFolders error:', error);
299+ homepageState.setFolders([]);
300+ return [];
301+ }
302+}
303+
304+export async function createFolder(req: FolderCreateRequest): Promise<Folder> {
305+ const folder = await sendMessage<Folder>('folder_create', { ...req });
306+ homepageState.addFolder(folder);
307+ return folder;
308+}
309+
310+export async function updateFolder(req: FolderUpdateRequest): Promise<void> {
311+ await sendMessage('folder_update', { ...req });
312+ homepageState.updateFolder({
313+ ...homepageState.folders.find(f => f.id === req.id)!,
314+ name: req.name,
315+ icon: req.icon ?? null,
316+ });
317+}
318+
319+export async function deleteFolder(id: number): Promise<void> {
320+ await sendMessage('folder_delete', { id });
321+ homepageState.deleteFolder(id);
322+}
323+
324+// ═══════════════════════════════════════════════════════════════════════════════
325+// TAGS API
326+// ═══════════════════════════════════════════════════════════════════════════════
327+
328+export async function fetchTags(): Promise<Tag[]> {
329+ try {
330+ const data = await sendMessage<Tag[]>('tag_list');
331+ const tags = Array.isArray(data) ? data : [];
332+ homepageState.setTags(tags);
333+ return tags;
334+ } catch (error) {
335+ console.error('[messaging] fetchTags error:', error);
336+ homepageState.setTags([]);
337+ return [];
338+ }
339+}
340+
341+export async function createTag(req: TagCreateRequest): Promise<Tag> {
342+ const tag = await sendMessage<Tag>('tag_create', { ...req });
343+ homepageState.addTag(tag);
344+ return tag;
345+}
346+
347+export async function updateTag(req: TagUpdateRequest): Promise<void> {
348+ await sendMessage('tag_update', { ...req });
349+ const existing = homepageState.tags.find(t => t.id === req.id);
350+ if (existing) {
351+ homepageState.updateTag({
352+ ...existing,
353+ name: req.name ?? existing.name,
354+ color: req.color ?? existing.color,
355+ });
356+ }
357+}
358+
359+export async function deleteTag(id: number): Promise<void> {
360+ await sendMessage('tag_delete', { id });
361+ homepageState.deleteTag(id);
362+}
363+
364+export async function assignTag(favoriteId: number, tagId: number): Promise<void> {
365+ await sendMessage('tag_assign', { favorite_id: favoriteId, tag_id: tagId });
366+ // Update local state
367+ const favorite = homepageState.favorites.find(f => f.id === favoriteId);
368+ const tag = homepageState.tags.find(t => t.id === tagId);
369+ if (favorite && tag) {
370+ homepageState.updateFavorite({
371+ ...favorite,
372+ tags: [...(favorite.tags || []), tag],
373+ });
374+ }
375+}
376+
377+export async function removeTag(favoriteId: number, tagId: number): Promise<void> {
378+ await sendMessage('tag_remove', { favorite_id: favoriteId, tag_id: tagId });
379+ // Update local state
380+ const favorite = homepageState.favorites.find(f => f.id === favoriteId);
381+ if (favorite) {
382+ homepageState.updateFavorite({
383+ ...favorite,
384+ tags: favorite.tags?.filter(t => t.id !== tagId),
385+ });
386+ }
387+}
388+
389+// ═══════════════════════════════════════════════════════════════════════════════
390+// FAVORITES API
391+// ═══════════════════════════════════════════════════════════════════════════════
392+
393+export async function fetchFavorites(): Promise<Favorite[]> {
394+ homepageState.setFavoritesLoading(true);
395+ try {
396+ const data = await sendMessage<Favorite[]>('favorite_list');
397+ const favorites = Array.isArray(data) ? data : [];
398+ homepageState.setFavorites(favorites);
399+ return favorites;
400+ } catch (error) {
401+ console.error('[messaging] fetchFavorites error:', error);
402+ homepageState.setFavorites([]);
403+ return [];
404+ } finally {
405+ homepageState.setFavoritesLoading(false);
406+ }
407+}
408+
409+export async function setFavoriteShortcut(
410+ favoriteId: number,
411+ shortcutKey: number | null
412+): Promise<void> {
413+ await sendMessage('favorite_set_shortcut', {
414+ favorite_id: favoriteId,
415+ shortcut_key: shortcutKey,
416+ });
417+ // Update local state
418+ const favorite = homepageState.favorites.find(f => f.id === favoriteId);
419+ if (favorite) {
420+ // Clear shortcut from any other favorite that had this key
421+ if (shortcutKey !== null) {
422+ for (const f of homepageState.favorites) {
423+ if (f.shortcut_key === shortcutKey && f.id !== favoriteId) {
424+ homepageState.updateFavorite({ ...f, shortcut_key: null });
425+ }
426+ }
427+ }
428+ homepageState.updateFavorite({ ...favorite, shortcut_key: shortcutKey });
429+ }
430+}
431+
432+export async function getFavoriteByShortcut(shortcutKey: number): Promise<Favorite | null> {
433+ try {
434+ const data = await sendMessage<Favorite | null>('favorite_get_by_shortcut', {
435+ shortcut_key: shortcutKey,
436+ });
437+ return data;
438+ } catch (error) {
439+ console.error('[messaging] getFavoriteByShortcut error:', error);
440+ return null;
441+ }
442+}
443+
444+export async function setFavoriteFolder(
445+ favoriteId: number,
446+ folderId: number | null
447+): Promise<void> {
448+ await sendMessage('favorite_set_folder', {
449+ favorite_id: favoriteId,
450+ folder_id: folderId,
451+ });
452+ // Update local state
453+ const favorite = homepageState.favorites.find(f => f.id === favoriteId);
454+ if (favorite) {
455+ homepageState.updateFavorite({ ...favorite, folder_id: folderId });
456+ }
457+}
458+
459+// ═══════════════════════════════════════════════════════════════════════════════
460+// ANALYTICS API
461+// ═══════════════════════════════════════════════════════════════════════════════
462+
463+export async function fetchAnalytics(): Promise<Analytics | null> {
464+ homepageState.setAnalyticsLoading(true);
465+ try {
466+ const data = await sendMessage<Analytics>('history_analytics');
467+ homepageState.setAnalytics(data);
468+ return data;
469+ } catch (error) {
470+ console.error('[messaging] fetchAnalytics error:', error);
471+ homepageState.setAnalytics(null);
472+ return null;
473+ } finally {
474+ homepageState.setAnalyticsLoading(false);
475+ }
476+}
477+
478+export async function fetchDomainStats(limit: number = 20): Promise<DomainStat[]> {
479+ try {
480+ const data = await sendMessage<DomainStat[]>('history_domain_stats', { limit });
481+ const stats = Array.isArray(data) ? data : [];
482+ homepageState.setDomainStats(stats);
483+ return stats;
484+ } catch (error) {
485+ console.error('[messaging] fetchDomainStats error:', error);
486+ homepageState.setDomainStats([]);
487+ return [];
488+ }
489+}
490+
491+// ═══════════════════════════════════════════════════════════════════════════════
492+// INITIALIZATION
493+// ═══════════════════════════════════════════════════════════════════════════════
494+
495+export async function initializeHomepage(): Promise<void> {
496+ // Initialize callbacks first
497+ initializeCallbacks();
498+
499+ // Fetch all initial data in parallel
500+ await Promise.all([
501+ fetchHistoryTimeline(),
502+ fetchFolders(),
503+ fetchTags(),
504+ fetchFavorites(),
505+ fetchAnalytics(),
506+ ]);
507+}
508+
509+// ═══════════════════════════════════════════════════════════════════════════════
510+// NAVIGATION
511+// ═══════════════════════════════════════════════════════════════════════════════
512+
513+export function navigateTo(url: string): void {
514+ window.location.href = url;
515+}
A gui/src/pages/homepage/panels/AnalyticsPanel.svelte +266 -0
@@ -0,0 +1,266 @@
1+<script lang="ts">
2+ import { homepageState } from '../state.svelte';
3+ import { fetchAnalytics } from '../messaging';
4+
5+ // Fetch analytics on mount
6+ $effect(() => {
7+ if (!homepageState.analytics) {
8+ fetchAnalytics();
9+ }
10+ });
11+
12+ const formatNumber = (n: number): string => {
13+ if (n >= 1000) {
14+ return `${(n / 1000).toFixed(1)}k`;
15+ }
16+ return n.toString();
17+ };
18+</script>
19+
20+<div class="analytics-panel">
21+ {#if homepageState.analyticsLoading}
22+ <div class="loading-state">
23+ <span class="loading-spinner"></span>
24+ <span class="loading-text">LOADING ANALYTICS...</span>
25+ </div>
26+ {:else if homepageState.analytics}
27+ <div class="stats-grid">
28+ <div class="stat-card">
29+ <span class="stat-value">{formatNumber(homepageState.analytics.stats.total_entries)}</span>
30+ <span class="stat-label">TOTAL ENTRIES</span>
31+ </div>
32+ <div class="stat-card">
33+ <span class="stat-value">{formatNumber(homepageState.analytics.stats.total_visits)}</span>
34+ <span class="stat-label">TOTAL VISITS</span>
35+ </div>
36+ <div class="stat-card">
37+ <span class="stat-value">{homepageState.analytics.stats.unique_days}</span>
38+ <span class="stat-label">DAYS ACTIVE</span>
39+ </div>
40+ <div class="stat-card">
41+ <span class="stat-value">{homepageState.domainStats.length}</span>
42+ <span class="stat-label">UNIQUE DOMAINS</span>
43+ </div>
44+ </div>
45+
46+ <div class="section">
47+ <div class="section-header">
48+ <span class="section-title">TOP DOMAINS</span>
49+ </div>
50+ <div class="domain-list">
51+ {#each homepageState.domainStats.slice(0, 10) as domain, i (domain.domain)}
52+ <div class="domain-item">
53+ <span class="domain-rank">{i + 1}</span>
54+ <span class="domain-name">{domain.domain}</span>
55+ <span class="domain-stats">
56+ <span class="domain-pages">{domain.page_count} pages</span>
57+ <span class="domain-visits">{domain.total_visits} visits</span>
58+ </span>
59+ </div>
60+ {/each}
61+ </div>
62+ </div>
63+
64+ {#if homepageState.analytics.hourly_distribution.length > 0}
65+ <div class="section">
66+ <div class="section-header">
67+ <span class="section-title">ACTIVITY BY HOUR</span>
68+ </div>
69+ <div class="hourly-chart">
70+ {#each homepageState.analytics.hourly_distribution as hour (hour.hour)}
71+ {@const maxVisits = Math.max(...homepageState.analytics.hourly_distribution.map(h => h.visit_count))}
72+ {@const heightPercent = maxVisits > 0 ? (hour.visit_count / maxVisits) * 100 : 0}
73+ <div class="hour-bar" title="{hour.hour}:00 - {hour.visit_count} visits">
74+ <div class="bar-fill" style="height: {heightPercent}%"></div>
75+ <span class="hour-label">{hour.hour}</span>
76+ </div>
77+ {/each}
78+ </div>
79+ </div>
80+ {/if}
81+ {:else}
82+ <div class="empty-state">
83+ <span class="empty-icon"></span>
84+ <span class="empty-text">NO ANALYTICS DATA</span>
85+ <span class="empty-hint">Browse some sites to generate analytics</span>
86+ </div>
87+ {/if}
88+</div>
89+
90+<style>
91+ .analytics-panel {
92+ display: flex;
93+ flex-direction: column;
94+ gap: 1rem;
95+ flex: 1;
96+ min-height: 0;
97+ overflow-y: auto;
98+ padding: 0.5rem 1rem;
99+ }
100+
101+ .loading-state,
102+ .empty-state {
103+ flex: 1;
104+ display: flex;
105+ flex-direction: column;
106+ align-items: center;
107+ justify-content: center;
108+ gap: 0.5rem;
109+ padding: 2rem;
110+ color: var(--dynamic-muted);
111+ text-align: center;
112+ }
113+
114+ .loading-spinner {
115+ width: 24px;
116+ height: 24px;
117+ border: 2px solid var(--dynamic-border);
118+ border-top-color: var(--dynamic-accent, #4ade80);
119+ border-radius: 50%;
120+ animation: spin 0.8s linear infinite;
121+ }
122+
123+ @keyframes spin {
124+ to { transform: rotate(360deg); }
125+ }
126+
127+ .loading-text,
128+ .empty-text {
129+ font-size: 0.68rem;
130+ font-weight: 600;
131+ letter-spacing: 0.12em;
132+ }
133+
134+ .empty-icon {
135+ font-size: 1.5rem;
136+ opacity: 0.5;
137+ }
138+
139+ .empty-hint {
140+ font-size: 0.65rem;
141+ letter-spacing: 0.06em;
142+ opacity: 0.7;
143+ }
144+
145+ .stats-grid {
146+ display: grid;
147+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
148+ gap: 0.5rem;
149+ }
150+
151+ .stat-card {
152+ display: flex;
153+ flex-direction: column;
154+ gap: 0.3rem;
155+ padding: 0.85rem;
156+ border: 1px solid var(--dynamic-border);
157+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
158+ }
159+
160+ .stat-value {
161+ font-size: 1.5rem;
162+ font-weight: 700;
163+ color: var(--dynamic-text);
164+ letter-spacing: -0.02em;
165+ }
166+
167+ .stat-label {
168+ font-size: 0.6rem;
169+ font-weight: 600;
170+ color: var(--dynamic-muted);
171+ letter-spacing: 0.12em;
172+ }
173+
174+ .section {
175+ border: 1px solid var(--dynamic-border);
176+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
177+ }
178+
179+ .section-header {
180+ padding: 0.5rem 0.75rem;
181+ border-bottom: 1px solid var(--dynamic-border);
182+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
183+ }
184+
185+ .section-title {
186+ font-size: 0.6rem;
187+ font-weight: 600;
188+ color: var(--dynamic-muted);
189+ letter-spacing: 0.12em;
190+ }
191+
192+ .domain-list {
193+ display: flex;
194+ flex-direction: column;
195+ }
196+
197+ .domain-item {
198+ display: grid;
199+ grid-template-columns: auto 1fr auto;
200+ align-items: center;
201+ gap: 0.75rem;
202+ padding: 0.5rem 0.75rem;
203+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 40%, transparent);
204+ }
205+
206+ .domain-item:last-child {
207+ border-bottom: none;
208+ }
209+
210+ .domain-rank {
211+ width: 1.5rem;
212+ font-size: 0.65rem;
213+ font-weight: 600;
214+ color: var(--dynamic-muted);
215+ text-align: center;
216+ }
217+
218+ .domain-name {
219+ font-size: 0.75rem;
220+ color: var(--dynamic-text);
221+ white-space: nowrap;
222+ overflow: hidden;
223+ text-overflow: ellipsis;
224+ }
225+
226+ .domain-stats {
227+ display: flex;
228+ gap: 0.75rem;
229+ font-size: 0.6rem;
230+ color: var(--dynamic-muted);
231+ }
232+
233+ .hourly-chart {
234+ display: flex;
235+ align-items: flex-end;
236+ gap: 2px;
237+ height: 80px;
238+ padding: 0.75rem;
239+ }
240+
241+ .hour-bar {
242+ flex: 1;
243+ display: flex;
244+ flex-direction: column;
245+ align-items: center;
246+ height: 100%;
247+ }
248+
249+ .bar-fill {
250+ width: 100%;
251+ background: var(--dynamic-accent, #4ade80);
252+ margin-top: auto;
253+ min-height: 2px;
254+ transition: height 300ms ease;
255+ }
256+
257+ .hour-label {
258+ font-size: 0.5rem;
259+ color: var(--dynamic-muted);
260+ margin-top: 0.25rem;
261+ }
262+
263+ .hour-bar:nth-child(odd) .hour-label {
264+ visibility: hidden;
265+ }
266+</style>
A gui/src/pages/homepage/panels/FavoritesPanel.svelte +287 -0
@@ -0,0 +1,287 @@
1+<script lang="ts">
2+ import { homepageState } from '../state.svelte';
3+ import {
4+ navigateTo,
5+ setFavoriteShortcut,
6+ setFavoriteFolder,
7+ assignTag,
8+ removeTag,
9+ createFolder,
10+ createTag
11+ } from '../messaging';
12+ import type { Favorite } from '../types';
13+
14+ import { FolderList, TagCloud, FavoriteGrid, FavoriteEditor } from '../favorites';
15+
16+ let editorOpen = $state(false);
17+ let editingFavorite = $state<Favorite | null>(null);
18+
19+ // Get filtered favorites based on folder and tag selection
20+ const displayedFavorites = $derived(() => {
21+ let result = homepageState.favorites;
22+
23+ // Filter by folder
24+ const folderId = homepageState.selectedFolderId;
25+ if (folderId !== null) {
26+ if (folderId === -1) {
27+ // Show unfiled
28+ result = result.filter(f => f.folder_id === null);
29+ } else {
30+ result = result.filter(f => f.folder_id === folderId);
31+ }
32+ }
33+
34+ // Filter by tags
35+ const tagIds = homepageState.selectedTagIds;
36+ if (tagIds.length > 0) {
37+ result = result.filter(f =>
38+ f.tags?.some(t => tagIds.includes(t.id))
39+ );
40+ }
41+
42+ return result;
43+ });
44+
45+ const handleSelectFavorite = (fav: Favorite) => {
46+ navigateTo(fav.url);
47+ };
48+
49+ const handleEditFavorite = (fav: Favorite) => {
50+ editingFavorite = fav;
51+ editorOpen = true;
52+ };
53+
54+ const handleSaveFavorite = async (updates: Partial<Favorite>) => {
55+ if (!updates.id) return;
56+
57+ // Update shortcut key
58+ if (updates.shortcut_key !== undefined) {
59+ await setFavoriteShortcut(updates.id, updates.shortcut_key);
60+ }
61+
62+ // Update folder
63+ if (updates.folder_id !== undefined) {
64+ await setFavoriteFolder(updates.id, updates.folder_id);
65+ }
66+
67+ // Title update would need a separate handler - for now just update local state
68+ if (updates.title !== undefined && editingFavorite) {
69+ homepageState.updateFavorite({
70+ ...editingFavorite,
71+ ...updates
72+ });
73+ }
74+ };
75+
76+ const handleDeleteFavorite = (id: number) => {
77+ // This would call a delete API - for now just update local state
78+ homepageState.deleteFavorite(id);
79+ };
80+
81+ const handleAssignTag = async (favoriteId: number, tagId: number) => {
82+ await assignTag(favoriteId, tagId);
83+ };
84+
85+ const handleRemoveTag = async (favoriteId: number, tagId: number) => {
86+ await removeTag(favoriteId, tagId);
87+ };
88+
89+ const handleSelectFolder = (id: number | null) => {
90+ homepageState.selectFolder(id);
91+ };
92+
93+ const handleToggleTag = (id: number) => {
94+ homepageState.toggleTag(id);
95+ };
96+
97+ const handleClearTagSelection = () => {
98+ homepageState.clearTagSelection();
99+ };
100+
101+ const handleCreateFolder = async (name: string) => {
102+ await createFolder({ name });
103+ };
104+
105+ const handleCreateTag = async (name: string, color: string) => {
106+ await createTag({ name, color });
107+ };
108+
109+ const closeEditor = () => {
110+ editorOpen = false;
111+ editingFavorite = null;
112+ };
113+</script>
114+
115+<div class="favorites-panel">
116+ <div class="panel-sidebar">
117+ <FolderList
118+ folders={homepageState.folders}
119+ favorites={homepageState.favorites}
120+ selectedFolderId={homepageState.selectedFolderId}
121+ onSelectFolder={handleSelectFolder}
122+ onCreateFolder={handleCreateFolder}
123+ />
124+
125+ <TagCloud
126+ tags={homepageState.tags}
127+ selectedTagIds={homepageState.selectedTagIds}
128+ onToggleTag={handleToggleTag}
129+ onClearSelection={handleClearTagSelection}
130+ onCreateTag={handleCreateTag}
131+ />
132+ </div>
133+
134+ <div class="panel-main">
135+ <div class="panel-header">
136+ <span class="header-title">
137+ {#if homepageState.selectedFolderId === null}
138+ ALL FAVORITES
139+ {:else if homepageState.selectedFolderId === -1}
140+ UNFILED
141+ {:else}
142+ {homepageState.folders.find(f => f.id === homepageState.selectedFolderId)?.name ?? 'FOLDER'}
143+ {/if}
144+ </span>
145+ <span class="header-count">{displayedFavorites().length}</span>
146+ </div>
147+
148+ <div class="panel-content">
149+ {#if homepageState.favoritesLoading}
150+ <div class="loading-state">
151+ <span class="loading-spinner"></span>
152+ <span class="loading-text">LOADING FAVORITES...</span>
153+ </div>
154+ {:else}
155+ <FavoriteGrid
156+ favorites={displayedFavorites()}
157+ onSelectFavorite={handleSelectFavorite}
158+ onEditFavorite={handleEditFavorite}
159+ />
160+ {/if}
161+ </div>
162+ </div>
163+
164+ {#if editorOpen && editingFavorite}
165+ <FavoriteEditor
166+ favorite={editingFavorite}
167+ folders={homepageState.folders}
168+ tags={homepageState.tags}
169+ onSave={handleSaveFavorite}
170+ onDelete={handleDeleteFavorite}
171+ onClose={closeEditor}
172+ onAssignTag={handleAssignTag}
173+ onRemoveTag={handleRemoveTag}
174+ />
175+ {/if}
176+</div>
177+
178+<style>
179+ .favorites-panel {
180+ display: grid;
181+ grid-template-columns: 200px 1fr;
182+ gap: 0.75rem;
183+ flex: 1;
184+ min-height: 0;
185+ padding: 0.5rem 1rem;
186+ }
187+
188+ .panel-sidebar {
189+ display: flex;
190+ flex-direction: column;
191+ gap: 0.5rem;
192+ overflow-y: auto;
193+ }
194+
195+ .panel-main {
196+ display: flex;
197+ flex-direction: column;
198+ min-height: 0;
199+ border: 1px solid var(--dynamic-border);
200+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
201+ }
202+
203+ .panel-header {
204+ display: flex;
205+ align-items: center;
206+ justify-content: space-between;
207+ padding: 0.6rem 0.85rem;
208+ border-bottom: 1px solid var(--dynamic-border);
209+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
210+ }
211+
212+ .header-title {
213+ font-size: 0.68rem;
214+ font-weight: 600;
215+ letter-spacing: 0.12em;
216+ color: var(--dynamic-text);
217+ }
218+
219+ .header-count {
220+ font-size: 0.6rem;
221+ padding: 0.15rem 0.4rem;
222+ border: 1px solid var(--dynamic-border);
223+ background: var(--dynamic-bg);
224+ color: var(--dynamic-muted);
225+ }
226+
227+ .panel-content {
228+ flex: 1;
229+ min-height: 0;
230+ padding: 0.75rem;
231+ overflow-y: auto;
232+ }
233+
234+ .loading-state {
235+ flex: 1;
236+ display: flex;
237+ flex-direction: column;
238+ align-items: center;
239+ justify-content: center;
240+ gap: 0.75rem;
241+ padding: 2rem;
242+ color: var(--dynamic-muted);
243+ }
244+
245+ .loading-spinner {
246+ width: 24px;
247+ height: 24px;
248+ border: 2px solid var(--dynamic-border);
249+ border-top-color: var(--dynamic-accent, #4ade80);
250+ border-radius: 50%;
251+ animation: spin 0.8s linear infinite;
252+ }
253+
254+ @keyframes spin {
255+ to { transform: rotate(360deg); }
256+ }
257+
258+ .loading-text {
259+ font-size: 0.68rem;
260+ letter-spacing: 0.12em;
261+ animation: pulse 1.5s ease-in-out infinite;
262+ }
263+
264+ @keyframes pulse {
265+ 0%, 100% { opacity: 0.5; }
266+ 50% { opacity: 1; }
267+ }
268+
269+ /* Responsive: stack on smaller screens */
270+ @media (max-width: 768px) {
271+ .favorites-panel {
272+ grid-template-columns: 1fr;
273+ grid-template-rows: auto 1fr;
274+ }
275+
276+ .panel-sidebar {
277+ flex-direction: row;
278+ gap: 0.5rem;
279+ overflow-x: auto;
280+ }
281+
282+ .panel-sidebar > :global(*) {
283+ flex-shrink: 0;
284+ min-width: 180px;
285+ }
286+ }
287+</style>
A gui/src/pages/homepage/panels/HistoryPanel.svelte +209 -0
@@ -0,0 +1,209 @@
1+<script lang="ts">
2+ import { homepageState } from '../state.svelte';
3+ import {
4+ fetchHistoryTimeline,
5+ deleteHistoryEntry,
6+ deleteHistoryByRange,
7+ clearAllHistory,
8+ fetchDomainStats,
9+ navigateTo
10+ } from '../messaging';
11+ import type { HistoryEntry, HistoryCleanupRange } from '../types';
12+
13+ import { HistoryTimeline, HistorySearch, HistoryFilters, HistoryCleanup } from '../history';
14+
15+ let cleanupOpen = $state(false);
16+ let selectedDomain = $state<string | null>(null);
17+
18+ // Filter entries by domain if selected
19+ const displayedEntries = $derived(() => {
20+ if (!selectedDomain) {
21+ return homepageState.historySearchQuery
22+ ? homepageState.historySearchResults
23+ : homepageState.history;
24+ }
25+
26+ const entries = homepageState.historySearchQuery
27+ ? homepageState.historySearchResults
28+ : homepageState.history;
29+
30+ return entries.filter(entry => {
31+ try {
32+ return new URL(entry.url).hostname === selectedDomain;
33+ } catch {
34+ return false;
35+ }
36+ });
37+ });
38+
39+ // Recompute timeline from filtered entries
40+ const displayedTimeline = $derived(() => {
41+ const entries = displayedEntries();
42+ if (entries.length === 0) return [];
43+
44+ const groups = new Map<string, HistoryEntry[]>();
45+ const today = new Date();
46+ today.setHours(0, 0, 0, 0);
47+ const yesterday = new Date(today);
48+ yesterday.setDate(yesterday.getDate() - 1);
49+
50+ for (const entry of entries) {
51+ const date = new Date(entry.last_visited);
52+ const dateKey = date.toISOString().split('T')[0] ?? '';
53+ if (!groups.has(dateKey)) {
54+ groups.set(dateKey, []);
55+ }
56+ groups.get(dateKey)!.push(entry);
57+ }
58+
59+ const result: { date: string; label: string; entries: HistoryEntry[] }[] = [];
60+ for (const [dateKey, items] of groups) {
61+ const date = new Date(dateKey);
62+ let label: string;
63+
64+ if (date >= today) {
65+ label = 'Today';
66+ } else if (date >= yesterday) {
67+ label = 'Yesterday';
68+ } else {
69+ label = date.toLocaleDateString(undefined, {
70+ weekday: 'short',
71+ month: 'short',
72+ day: 'numeric',
73+ });
74+ }
75+
76+ result.push({ date: dateKey, label, entries: items });
77+ }
78+
79+ return result.sort((a, b) => b.date.localeCompare(a.date));
80+ });
81+
82+ const handleSelectEntry = (entry: HistoryEntry) => {
83+ navigateTo(entry.url);
84+ };
85+
86+ const handleDeleteEntry = async (entry: HistoryEntry) => {
87+ await deleteHistoryEntry(entry.id);
88+ };
89+
90+ const handleLoadMore = async () => {
91+ if (homepageState.hasMoreHistory && !homepageState.historyLoading) {
92+ await fetchHistoryTimeline(50, homepageState.historyOffset);
93+ }
94+ };
95+
96+ const handleCleanup = async (range: HistoryCleanupRange) => {
97+ if (range === 'all') {
98+ await clearAllHistory();
99+ } else {
100+ await deleteHistoryByRange(range);
101+ }
102+ cleanupOpen = false;
103+ };
104+
105+ const handleSelectDomain = (domain: string | null) => {
106+ selectedDomain = domain;
107+ homepageState.setFocusedIndex(0);
108+ };
109+
110+ // Fetch domain stats on mount
111+ $effect(() => {
112+ fetchDomainStats(10);
113+ });
114+</script>
115+
116+<div class="history-panel">
117+ <div class="panel-toolbar">
118+ <HistorySearch placeholder="Search history (FTS)..." autofocus={true} />
119+ </div>
120+
121+ <HistoryFilters
122+ domains={homepageState.domainStats}
123+ selectedDomain={selectedDomain}
124+ onSelectDomain={handleSelectDomain}
125+ onOpenCleanup={() => cleanupOpen = true}
126+ />
127+
128+ <div class="panel-content">
129+ {#if homepageState.historyLoading && homepageState.history.length === 0}
130+ <div class="loading-state">
131+ <span class="loading-spinner"></span>
132+ <span class="loading-text">LOADING HISTORY...</span>
133+ </div>
134+ {:else}
135+ <HistoryTimeline
136+ groups={displayedTimeline()}
137+ onSelectEntry={handleSelectEntry}
138+ onDeleteEntry={handleDeleteEntry}
139+ onLoadMore={handleLoadMore}
140+ />
141+ {/if}
142+ </div>
143+
144+ {#if cleanupOpen}
145+ <HistoryCleanup
146+ onCleanup={handleCleanup}
147+ onClose={() => cleanupOpen = false}
148+ />
149+ {/if}
150+</div>
151+
152+<style>
153+ .history-panel {
154+ display: flex;
155+ flex-direction: column;
156+ flex: 1;
157+ min-height: 0;
158+ gap: 0.5rem;
159+ padding: 0.5rem 1rem;
160+ }
161+
162+ .panel-toolbar {
163+ flex-shrink: 0;
164+ }
165+
166+ .panel-content {
167+ flex: 1;
168+ min-height: 0;
169+ display: flex;
170+ flex-direction: column;
171+ border: 1px solid var(--dynamic-border);
172+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
173+ overflow-y: auto;
174+ }
175+
176+ .loading-state {
177+ flex: 1;
178+ display: flex;
179+ flex-direction: column;
180+ align-items: center;
181+ justify-content: center;
182+ gap: 0.75rem;
183+ color: var(--dynamic-muted);
184+ }
185+
186+ .loading-spinner {
187+ width: 24px;
188+ height: 24px;
189+ border: 2px solid var(--dynamic-border);
190+ border-top-color: var(--dynamic-accent, #4ade80);
191+ border-radius: 50%;
192+ animation: spin 0.8s linear infinite;
193+ }
194+
195+ @keyframes spin {
196+ to { transform: rotate(360deg); }
197+ }
198+
199+ .loading-text {
200+ font-size: 0.68rem;
201+ letter-spacing: 0.12em;
202+ animation: pulse 1.5s ease-in-out infinite;
203+ }
204+
205+ @keyframes pulse {
206+ 0%, 100% { opacity: 0.5; }
207+ 50% { opacity: 1; }
208+ }
209+</style>
A gui/src/pages/homepage/panels/index.ts +4 -0
@@ -0,0 +1,4 @@
1+// Panel Components - Main content panels for homepage
2+export { default as HistoryPanel } from './HistoryPanel.svelte';
3+export { default as FavoritesPanel } from './FavoritesPanel.svelte';
4+export { default as AnalyticsPanel } from './AnalyticsPanel.svelte';
A gui/src/pages/homepage/shell/CommandPalette.svelte +580 -0
@@ -0,0 +1,580 @@
1+<script lang="ts">
2+ import { onMount, tick } from 'svelte';
3+ import { homepageState } from '../state.svelte';
4+ import { navigateTo } from '../messaging';
5+ import type { CommandPaletteItem } from '../types';
6+ import { Search, SearchX, History, Star, BarChart3, Trash2, Globe } from '@lucide/svelte';
7+
8+ // Input element reference
9+ let inputRef: HTMLInputElement | null = $state(null);
10+ let listRef: HTMLDivElement | null = $state(null);
11+
12+ // Local state for command palette
13+ let query = $state('');
14+ let selectedIndex = $state(0);
15+
16+ // Build command items from available actions
17+ const staticCommands: CommandPaletteItem[] = [
18+ {
19+ id: 'nav:history',
20+ label: 'Go to History',
21+ description: 'View browsing history timeline',
22+ icon: History,
23+ shortcut: 'g h',
24+ action: () => {
25+ homepageState.setActivePanel('history');
26+ homepageState.closeCommandPalette();
27+ },
28+ },
29+ {
30+ id: 'nav:favorites',
31+ label: 'Go to Favorites',
32+ description: 'View bookmarks and folders',
33+ icon: Star,
34+ shortcut: 'g f',
35+ action: () => {
36+ homepageState.setActivePanel('favorites');
37+ homepageState.closeCommandPalette();
38+ },
39+ },
40+ {
41+ id: 'nav:analytics',
42+ label: 'Go to Analytics',
43+ description: 'View browsing statistics',
44+ icon: BarChart3,
45+ shortcut: 'g a',
46+ action: () => {
47+ homepageState.setActivePanel('analytics');
48+ homepageState.closeCommandPalette();
49+ },
50+ },
51+ {
52+ id: 'action:cleanup',
53+ label: 'Clean History',
54+ description: 'Remove history entries by time range',
55+ icon: Trash2,
56+ action: () => {
57+ homepageState.closeCommandPalette();
58+ homepageState.openCleanupModal();
59+ },
60+ },
61+ ];
62+
63+ // Fuzzy match function
64+ function fuzzyMatch(text: string, pattern: string): { match: boolean; score: number } {
65+ if (!pattern) return { match: true, score: 0 };
66+
67+ const lower = text.toLowerCase();
68+ const pLower = pattern.toLowerCase();
69+
70+ // Exact substring match scores highest
71+ if (lower.includes(pLower)) {
72+ return { match: true, score: 100 - lower.indexOf(pLower) };
73+ }
74+
75+ // Fuzzy character matching
76+ let pIdx = 0;
77+ let score = 0;
78+ let consecutive = 0;
79+
80+ for (let i = 0; i < lower.length && pIdx < pLower.length; i++) {
81+ if (lower[i] === pLower[pIdx]) {
82+ pIdx++;
83+ consecutive++;
84+ score += consecutive * 2;
85+ } else {
86+ consecutive = 0;
87+ }
88+ }
89+
90+ return { match: pIdx === pLower.length, score };
91+ }
92+
93+ // Build filtered items based on query
94+ const filteredItems = $derived.by(() => {
95+ const results: Array<{ item: CommandPaletteItem; score: number }> = [];
96+
97+ // Add static commands
98+ for (const cmd of staticCommands) {
99+ const labelMatch = fuzzyMatch(cmd.label, query);
100+ const descMatch = cmd.description ? fuzzyMatch(cmd.description, query) : { match: false, score: 0 };
101+
102+ if (labelMatch.match || descMatch.match) {
103+ results.push({
104+ item: cmd,
105+ score: Math.max(labelMatch.score, descMatch.score * 0.8),
106+ });
107+ }
108+ }
109+
110+ // Add history items as navigable commands
111+ for (const entry of homepageState.history.slice(0, 50)) {
112+ const titleMatch = fuzzyMatch(entry.title || '', query);
113+ const urlMatch = fuzzyMatch(entry.url, query);
114+
115+ if (titleMatch.match || urlMatch.match) {
116+ results.push({
117+ item: {
118+ id: `history:${entry.id}`,
119+ label: entry.title || getDomain(entry.url),
120+ description: entry.url,
121+ icon: Globe,
122+ action: () => {
123+ navigateTo(entry.url);
124+ },
125+ },
126+ score: Math.max(titleMatch.score, urlMatch.score * 0.7),
127+ });
128+ }
129+ }
130+
131+ // Add favorites as navigable commands
132+ for (const fav of homepageState.favorites) {
133+ const titleMatch = fuzzyMatch(fav.title || '', query);
134+ const urlMatch = fuzzyMatch(fav.url, query);
135+
136+ if (titleMatch.match || urlMatch.match) {
137+ results.push({
138+ item: {
139+ id: `fav:${fav.id}`,
140+ label: fav.title || getDomain(fav.url),
141+ description: fav.url,
142+ icon: Star,
143+ shortcut: fav.shortcut_key ? `${fav.shortcut_key}` : undefined,
144+ action: () => {
145+ navigateTo(fav.url);
146+ },
147+ },
148+ score: Math.max(titleMatch.score, urlMatch.score * 0.7) + 10, // Boost favorites
149+ });
150+ }
151+ }
152+
153+ // Sort by score and return items
154+ results.sort((a, b) => b.score - a.score);
155+ return results.map(r => r.item).slice(0, 20);
156+ });
157+
158+ function getDomain(url: string): string {
159+ try {
160+ return new URL(url).hostname;
161+ } catch {
162+ return url;
163+ }
164+ }
165+
166+ // Reset selection when query changes
167+ $effect(() => {
168+ query; // Track query
169+ selectedIndex = 0;
170+ });
171+
172+ // Scroll selected item into view
173+ $effect(() => {
174+ if (listRef) {
175+ const selected = listRef.querySelector(`[data-index="${selectedIndex}"]`);
176+ selected?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
177+ }
178+ });
179+
180+ function handleKeydown(e: KeyboardEvent) {
181+ switch (e.key) {
182+ case 'ArrowDown':
183+ case 'j':
184+ if (e.key === 'j' && !e.ctrlKey) break;
185+ e.preventDefault();
186+ selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
187+ break;
188+
189+ case 'ArrowUp':
190+ case 'k':
191+ if (e.key === 'k' && !e.ctrlKey) break;
192+ e.preventDefault();
193+ selectedIndex = Math.max(selectedIndex - 1, 0);
194+ break;
195+
196+ case 'n':
197+ if (e.ctrlKey) {
198+ e.preventDefault();
199+ selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
200+ }
201+ break;
202+
203+ case 'p':
204+ if (e.ctrlKey) {
205+ e.preventDefault();
206+ selectedIndex = Math.max(selectedIndex - 1, 0);
207+ }
208+ break;
209+
210+ case 'Enter':
211+ e.preventDefault();
212+ executeSelected();
213+ break;
214+
215+ case 'Escape':
216+ e.preventDefault();
217+ homepageState.closeCommandPalette();
218+ break;
219+ }
220+ }
221+
222+ function executeSelected() {
223+ const item = filteredItems[selectedIndex];
224+ if (item) {
225+ item.action();
226+ }
227+ }
228+
229+ function handleItemClick(index: number) {
230+ selectedIndex = index;
231+ executeSelected();
232+ }
233+
234+ onMount(async () => {
235+ await tick();
236+ inputRef?.focus();
237+ });
238+</script>
239+
240+<div class="palette-overlay" onclick={() => homepageState.closeCommandPalette()}>
241+ <div class="palette-container" onclick={(e) => e.stopPropagation()}>
242+ <!-- Search Header -->
243+ <div class="palette-header">
244+ <div class="search-row">
245+ <Search size={16} strokeWidth={2} class="search-icon" />
246+ <input
247+ bind:this={inputRef}
248+ bind:value={query}
249+ type="text"
250+ class="search-input"
251+ placeholder="Search commands, history, favorites..."
252+ spellcheck="false"
253+ autocomplete="off"
254+ onkeydown={handleKeydown}
255+ />
256+ <div class="search-meta">
257+ <span class="result-count">{filteredItems.length}</span>
258+ <kbd class="esc-hint">esc</kbd>
259+ </div>
260+ </div>
261+ </div>
262+
263+ <!-- Results List -->
264+ <div class="palette-results" bind:this={listRef}>
265+ {#if filteredItems.length === 0}
266+ <div class="empty-results">
267+ <SearchX size={24} strokeWidth={1.5} class="empty-icon" />
268+ <span class="empty-text">No matches found</span>
269+ </div>
270+ {:else}
271+ {#each filteredItems as item, index (item.id)}
272+ <button
273+ class="result-item"
274+ class:selected={index === selectedIndex}
275+ data-index={index}
276+ type="button"
277+ onclick={() => handleItemClick(index)}
278+ onmouseenter={() => selectedIndex = index}
279+ >
280+ <span class="item-icon">
281+ {#if typeof item.icon === 'function'}
282+ <svelte:component this={item.icon} size={16} strokeWidth={2} />
283+ {:else if item.icon}
284+ {item.icon}
285+ {/if}
286+ </span>
287+ <div class="item-content">
288+ <span class="item-label">{item.label}</span>
289+ {#if item.description}
290+ <span class="item-description">{item.description}</span>
291+ {/if}
292+ </div>
293+ {#if item.shortcut}
294+ <kbd class="item-shortcut">{item.shortcut}</kbd>
295+ {/if}
296+ </button>
297+ {/each}
298+ {/if}
299+ </div>
300+
301+ <!-- Footer Hints -->
302+ <div class="palette-footer">
303+ <div class="hint-group">
304+ <kbd></kbd><kbd></kbd>
305+ <span>navigate</span>
306+ </div>
307+ <div class="hint-group">
308+ <kbd></kbd>
309+ <span>select</span>
310+ </div>
311+ <div class="hint-group">
312+ <kbd>esc</kbd>
313+ <span>close</span>
314+ </div>
315+ </div>
316+ </div>
317+</div>
318+
319+<style>
320+ .palette-overlay {
321+ position: fixed;
322+ inset: 0;
323+ z-index: 200;
324+ display: flex;
325+ align-items: flex-start;
326+ justify-content: center;
327+ padding-top: 12vh;
328+ background: rgb(0 0 0 / 0.65);
329+ backdrop-filter: blur(8px);
330+ animation: overlay-in 150ms ease;
331+ }
332+
333+ @keyframes overlay-in {
334+ from {
335+ opacity: 0;
336+ backdrop-filter: blur(0);
337+ }
338+ }
339+
340+ .palette-container {
341+ width: 100%;
342+ max-width: 580px;
343+ margin: 0 1rem;
344+ display: flex;
345+ flex-direction: column;
346+ background: var(--dynamic-surface);
347+ border: 1px solid var(--dynamic-border);
348+ box-shadow:
349+ 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 30%, transparent),
350+ 0 32px 64px -16px rgb(0 0 0 / 0.7);
351+ animation: palette-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
352+ }
353+
354+ @keyframes palette-in {
355+ from {
356+ opacity: 0;
357+ transform: scale(0.96) translateY(-12px);
358+ }
359+ }
360+
361+ /* Header */
362+ .palette-header {
363+ padding: 0;
364+ border-bottom: 1px solid var(--dynamic-border);
365+ background: color-mix(in srgb, var(--dynamic-bg) 70%, transparent);
366+ }
367+
368+ .search-row {
369+ display: flex;
370+ align-items: center;
371+ gap: 0.75rem;
372+ padding: 0.85rem 1rem;
373+ }
374+
375+ .search-icon {
376+ font-size: 1rem;
377+ color: var(--dynamic-accent, #4ade80);
378+ flex-shrink: 0;
379+ }
380+
381+ .search-input {
382+ flex: 1;
383+ background: transparent;
384+ border: none;
385+ outline: none;
386+ font-size: 0.95rem;
387+ font-family: inherit;
388+ color: var(--dynamic-text);
389+ caret-color: var(--dynamic-accent, #4ade80);
390+ }
391+
392+ .search-input::placeholder {
393+ color: var(--dynamic-muted);
394+ opacity: 0.7;
395+ }
396+
397+ .search-meta {
398+ display: flex;
399+ align-items: center;
400+ gap: 0.5rem;
401+ flex-shrink: 0;
402+ }
403+
404+ .result-count {
405+ font-size: 0.7rem;
406+ color: var(--dynamic-muted);
407+ letter-spacing: 0.05em;
408+ }
409+
410+ .esc-hint {
411+ padding: 0.2rem 0.4rem;
412+ font-size: 0.65rem;
413+ color: var(--dynamic-muted);
414+ background: var(--dynamic-bg);
415+ border: 1px solid var(--dynamic-border);
416+ }
417+
418+ /* Results */
419+ .palette-results {
420+ max-height: 360px;
421+ overflow-y: auto;
422+ overscroll-behavior: contain;
423+ }
424+
425+ .empty-results {
426+ display: flex;
427+ flex-direction: column;
428+ align-items: center;
429+ justify-content: center;
430+ gap: 0.5rem;
431+ padding: 2.5rem 1rem;
432+ color: var(--dynamic-muted);
433+ }
434+
435+ .empty-icon {
436+ font-size: 1.5rem;
437+ opacity: 0.5;
438+ }
439+
440+ .empty-text {
441+ font-size: 0.8rem;
442+ letter-spacing: 0.05em;
443+ }
444+
445+ .result-item {
446+ width: 100%;
447+ display: flex;
448+ align-items: center;
449+ gap: 0.75rem;
450+ padding: 0.7rem 1rem;
451+ background: transparent;
452+ border: none;
453+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 40%, transparent);
454+ cursor: pointer;
455+ text-align: left;
456+ transition: background 80ms ease;
457+ }
458+
459+ .result-item:last-child {
460+ border-bottom: none;
461+ }
462+
463+ .result-item:hover {
464+ background: color-mix(in srgb, var(--dynamic-surface) 80%, var(--dynamic-bg) 20%);
465+ }
466+
467+ .result-item.selected {
468+ background: color-mix(in srgb, var(--dynamic-accent, #4ade80) 12%, transparent);
469+ box-shadow: inset 2px 0 0 var(--dynamic-accent, #4ade80);
470+ }
471+
472+ .item-icon {
473+ font-size: 1rem;
474+ color: var(--dynamic-muted);
475+ flex-shrink: 0;
476+ width: 1.5rem;
477+ text-align: center;
478+ }
479+
480+ .result-item.selected .item-icon {
481+ color: var(--dynamic-accent, #4ade80);
482+ }
483+
484+ .item-content {
485+ flex: 1;
486+ min-width: 0;
487+ display: flex;
488+ flex-direction: column;
489+ gap: 0.15rem;
490+ }
491+
492+ .item-label {
493+ font-size: 0.85rem;
494+ color: var(--dynamic-text);
495+ white-space: nowrap;
496+ overflow: hidden;
497+ text-overflow: ellipsis;
498+ }
499+
500+ .item-description {
501+ font-size: 0.72rem;
502+ color: var(--dynamic-muted);
503+ white-space: nowrap;
504+ overflow: hidden;
505+ text-overflow: ellipsis;
506+ }
507+
508+ .item-shortcut {
509+ padding: 0.2rem 0.45rem;
510+ font-size: 0.65rem;
511+ color: var(--dynamic-muted);
512+ background: var(--dynamic-bg);
513+ border: 1px solid var(--dynamic-border);
514+ flex-shrink: 0;
515+ }
516+
517+ .result-item.selected .item-shortcut {
518+ border-color: color-mix(in srgb, var(--dynamic-accent, #4ade80) 40%, var(--dynamic-border) 60%);
519+ color: var(--dynamic-text);
520+ }
521+
522+ /* Footer */
523+ .palette-footer {
524+ display: flex;
525+ align-items: center;
526+ justify-content: center;
527+ gap: 1.5rem;
528+ padding: 0.65rem 1rem;
529+ border-top: 1px solid var(--dynamic-border);
530+ background: color-mix(in srgb, var(--dynamic-bg) 70%, transparent);
531+ }
532+
533+ .hint-group {
534+ display: flex;
535+ align-items: center;
536+ gap: 0.35rem;
537+ font-size: 0.68rem;
538+ color: var(--dynamic-muted);
539+ }
540+
541+ .hint-group kbd {
542+ padding: 0.15rem 0.35rem;
543+ font-size: 0.62rem;
544+ background: var(--dynamic-bg);
545+ border: 1px solid var(--dynamic-border);
546+ }
547+
548+ /* Scrollbar */
549+ .palette-results::-webkit-scrollbar {
550+ width: 6px;
551+ }
552+
553+ .palette-results::-webkit-scrollbar-track {
554+ background: transparent;
555+ }
556+
557+ .palette-results::-webkit-scrollbar-thumb {
558+ background: var(--dynamic-border);
559+ }
560+
561+ .palette-results::-webkit-scrollbar-thumb:hover {
562+ background: var(--dynamic-muted);
563+ }
564+
565+ @media (max-width: 480px) {
566+ .palette-overlay {
567+ padding-top: 2rem;
568+ align-items: flex-start;
569+ }
570+
571+ .palette-container {
572+ max-height: calc(100vh - 4rem);
573+ }
574+
575+ .palette-footer {
576+ flex-wrap: wrap;
577+ gap: 0.75rem;
578+ }
579+ }
580+</style>
A gui/src/pages/homepage/shell/HomepageShell.svelte +570 -0
@@ -0,0 +1,570 @@
1+<script lang="ts">
2+ import { onMount, onDestroy } from 'svelte';
3+ import { homepageState } from '../state.svelte';
4+ import { initializeHomepage } from '../messaging';
5+ import { initializeKeyboard } from '../keyboard';
6+ import CommandPalette from './CommandPalette.svelte';
7+ import StatusBar from './StatusBar.svelte';
8+ import KeyboardHints from './KeyboardHints.svelte';
9+ import { History, Star, BarChart3, Search, Moon, Sun } from '@lucide/svelte';
10+
11+ // Props for slot content
12+ interface Props {
13+ children?: import('svelte').Snippet;
14+ }
15+ let { children }: Props = $props();
16+
17+ // Theme state
18+ type ThemeMode = 'light' | 'dark';
19+ let themeMode = $state<ThemeMode>('dark');
20+ let themeObserver: MutationObserver | null = null;
21+
22+ // Keyboard cleanup
23+ let cleanupKeyboard: (() => void) | null = null;
24+
25+ // Panel tabs config
26+ const panelTabs = [
27+ { id: 'history' as const, label: 'HIST', icon: History },
28+ { id: 'favorites' as const, label: 'FAV', icon: Star },
29+ { id: 'analytics' as const, label: 'STAT', icon: BarChart3 },
30+ ];
31+
32+ // Theme management
33+ const syncThemeState = () => {
34+ themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
35+ };
36+
37+ const toggleTheme = () => {
38+ const nextMode = themeMode === 'dark' ? 'light' : 'dark';
39+ const manager = (window as any).__dumber_color_scheme_manager as
40+ | { setUserPreference?: (theme: ThemeMode) => void }
41+ | undefined;
42+
43+ if (manager?.setUserPreference) {
44+ manager.setUserPreference(nextMode);
45+ return;
46+ }
47+
48+ if ((window as any).__dumber_setTheme) {
49+ (window as any).__dumber_setTheme(nextMode);
50+ localStorage.setItem('dumber.theme', nextMode);
51+ return;
52+ }
53+
54+ switch (nextMode) {
55+ case 'light':
56+ document.documentElement.classList.add('light');
57+ document.documentElement.classList.remove('dark');
58+ break;
59+ case 'dark':
60+ document.documentElement.classList.remove('light');
61+ document.documentElement.classList.add('dark');
62+ break;
63+ }
64+ localStorage.setItem('dumber.theme', nextMode);
65+ themeMode = nextMode;
66+ };
67+
68+ onMount(async () => {
69+ // Initialize keyboard navigation
70+ cleanupKeyboard = initializeKeyboard();
71+
72+ // Initialize data fetching
73+ await initializeHomepage();
74+
75+ // Theme synchronization
76+ syncThemeState();
77+ themeObserver = new MutationObserver(syncThemeState);
78+ themeObserver.observe(document.documentElement, {
79+ attributes: true,
80+ attributeFilter: ['class'],
81+ });
82+ });
83+
84+ onDestroy(() => {
85+ cleanupKeyboard?.();
86+ themeObserver?.disconnect();
87+ themeObserver = null;
88+ homepageState.reset();
89+ });
90+</script>
91+
92+<svelte:head>
93+ <title>dumb://home</title>
94+ <meta name="description" content="Dumber Browser - Homepage" />
95+ {@html `<style>
96+ html { background: var(--dynamic-bg, #0a0a0a); }
97+ body { background: var(--dynamic-bg, #0a0a0a); color: var(--dynamic-text, #e5e5e5); }
98+ /* Disable GTK/WebKit default focus rings - we handle focus styling ourselves */
99+ *:focus { outline: none !important; }
100+ *:focus-visible { outline: none !important; }
101+ button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
102+ </style>`}
103+</svelte:head>
104+
105+<div class="homepage-shell">
106+ <!-- Terminal Frame -->
107+ <div class="terminal-frame">
108+ <!-- Header -->
109+ <header class="terminal-header">
110+ <div class="header-left">
111+ <span class="terminal-path">dumb://home</span>
112+ <div class="header-tabs" role="tablist" aria-label="Homepage panels">
113+ {#each panelTabs as tab, index (tab.id)}
114+ <button
115+ class="tab-button"
116+ class:active={homepageState.activePanel === tab.id}
117+ onclick={() => homepageState.setActivePanel(tab.id)}
118+ onkeydown={(e) => {
119+ switch (e.key) {
120+ case 'Enter':
121+ case ' ':
122+ e.preventDefault();
123+ homepageState.setActivePanel(tab.id);
124+ break;
125+ case 'ArrowLeft':
126+ case 'ArrowUp':
127+ e.preventDefault();
128+ {
129+ const prevIndex = index === 0 ? panelTabs.length - 1 : index - 1;
130+ const prevTab = panelTabs[prevIndex];
131+ if (prevTab) {
132+ homepageState.setActivePanel(prevTab.id);
133+ (e.currentTarget.parentElement?.children[prevIndex] as HTMLElement)?.focus();
134+ }
135+ }
136+ break;
137+ case 'ArrowRight':
138+ case 'ArrowDown':
139+ e.preventDefault();
140+ {
141+ const nextIndex = index === panelTabs.length - 1 ? 0 : index + 1;
142+ const nextTab = panelTabs[nextIndex];
143+ if (nextTab) {
144+ homepageState.setActivePanel(nextTab.id);
145+ (e.currentTarget.parentElement?.children[nextIndex] as HTMLElement)?.focus();
146+ }
147+ }
148+ break;
149+ }
150+ }}
151+ type="button"
152+ role="tab"
153+ aria-selected={homepageState.activePanel === tab.id}
154+ tabindex={homepageState.activePanel === tab.id ? 0 : -1}
155+ >
156+ <tab.icon size={14} strokeWidth={2} class="tab-icon" />
157+ <span class="tab-label">{tab.label}</span>
158+ </button>
159+ {/each}
160+ </div>
161+ </div>
162+ <div class="header-right">
163+ <button
164+ class="action-button"
165+ type="button"
166+ onclick={() => homepageState.openCommandPalette()}
167+ title="Command Palette (Ctrl+P or /)"
168+ >
169+ <Search size={14} strokeWidth={2} />
170+ <span class="button-text">CMD</span>
171+ <kbd class="kbd-hint">/</kbd>
172+ </button>
173+ <button
174+ class="theme-toggle"
175+ type="button"
176+ onclick={toggleTheme}
177+ title={themeMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
178+ aria-pressed={themeMode === 'dark'}
179+ >
180+ {#if themeMode === 'dark'}
181+ <Moon size={18} strokeWidth={2} />
182+ {:else}
183+ <Sun size={18} strokeWidth={2} />
184+ {/if}
185+ </button>
186+ </div>
187+ </header>
188+
189+ <!-- Main Content Area -->
190+ <main class="terminal-body">
191+ {#if children}
192+ {@render children()}
193+ {:else}
194+ <div class="empty-shell">
195+ <span class="shell-prompt">$</span>
196+ <span class="shell-cursor">_</span>
197+ </div>
198+ {/if}
199+ </main>
200+
201+ <!-- Status Bar -->
202+ <StatusBar />
203+ </div>
204+
205+ <!-- Keyboard Hints Overlay -->
206+ <KeyboardHints />
207+
208+ <!-- Command Palette Modal -->
209+ {#if homepageState.commandPaletteOpen}
210+ <CommandPalette />
211+ {/if}
212+
213+ <!-- Confirmation Modal -->
214+ {#if homepageState.confirmModalOpen}
215+ <div class="modal-overlay" onclick={() => homepageState.cancelConfirm()}>
216+ <div class="confirm-modal" onclick={(e) => e.stopPropagation()}>
217+ <div class="modal-header">
218+ <span class="modal-icon"></span>
219+ <span class="modal-title">CONFIRM</span>
220+ </div>
221+ <p class="modal-message">{homepageState.confirmModalMessage}</p>
222+ <div class="modal-actions">
223+ <button
224+ class="modal-btn modal-btn-cancel"
225+ type="button"
226+ onclick={() => homepageState.cancelConfirm()}
227+ >
228+ CANCEL
229+ <kbd>Esc</kbd>
230+ </button>
231+ <button
232+ class="modal-btn modal-btn-confirm"
233+ type="button"
234+ onclick={() => homepageState.confirmAction()}
235+ >
236+ CONFIRM
237+ <kbd>Enter</kbd>
238+ </button>
239+ </div>
240+ </div>
241+ </div>
242+ {/if}
243+</div>
244+
245+<style>
246+ .homepage-shell {
247+ height: 100vh;
248+ display: flex;
249+ flex-direction: column;
250+ font-family: 'JetBrains Mono NF', ui-monospace, monospace;
251+ line-height: 1.5;
252+ color: var(--dynamic-text);
253+ background: var(--dynamic-bg);
254+ overflow: hidden;
255+ }
256+
257+ .terminal-frame {
258+ flex: 1;
259+ display: flex;
260+ flex-direction: column;
261+ width: 100%;
262+ background: color-mix(in srgb, var(--dynamic-surface) 60%, var(--dynamic-bg) 40%);
263+ }
264+
265+ /* Dark mode: subtle shadow */
266+ :global(.dark) .terminal-frame {
267+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
268+ }
269+
270+ /* Header */
271+ .terminal-header {
272+ display: flex;
273+ align-items: center;
274+ justify-content: space-between;
275+ gap: 1rem;
276+ padding: 0.75rem 1rem;
277+ border-bottom: 1px solid var(--dynamic-border);
278+ background: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
279+ }
280+
281+ .header-left {
282+ display: flex;
283+ align-items: center;
284+ gap: 1.5rem;
285+ }
286+
287+ .terminal-path {
288+ font-size: 0.8rem;
289+ font-weight: 600;
290+ color: var(--dynamic-accent, #4ade80);
291+ letter-spacing: 0.05em;
292+ }
293+
294+ .header-tabs {
295+ display: flex;
296+ gap: 0.25rem;
297+ }
298+
299+ .tab-button {
300+ display: flex;
301+ align-items: center;
302+ gap: 0.4rem;
303+ padding: 0.4rem 0.75rem;
304+ font-size: 0.7rem;
305+ font-weight: 500;
306+ letter-spacing: 0.1em;
307+ color: var(--dynamic-muted);
308+ background: transparent;
309+ border: 1px solid transparent;
310+ cursor: pointer;
311+ transition: all 120ms ease;
312+ }
313+
314+ .tab-button:hover {
315+ color: var(--dynamic-text);
316+ background: color-mix(in srgb, var(--dynamic-surface) 50%, transparent);
317+ }
318+
319+ .tab-button:focus-visible {
320+ color: var(--dynamic-text);
321+ border-color: var(--dynamic-accent, #4ade80);
322+ background: color-mix(in srgb, var(--dynamic-accent, #4ade80) 15%, transparent);
323+ }
324+
325+ .tab-button.active {
326+ color: var(--dynamic-text);
327+ background: var(--dynamic-surface);
328+ border-color: var(--dynamic-border);
329+ }
330+
331+ .tab-icon,
332+ :global(.tab-icon) {
333+ flex-shrink: 0;
334+ }
335+
336+ .header-right {
337+ display: flex;
338+ align-items: center;
339+ gap: 0.5rem;
340+ }
341+
342+ .action-button {
343+ display: flex;
344+ align-items: center;
345+ gap: 0.5rem;
346+ padding: 0.4rem 0.75rem;
347+ font-size: 0.7rem;
348+ font-weight: 500;
349+ letter-spacing: 0.08em;
350+ color: var(--dynamic-muted);
351+ background: transparent;
352+ border: 1px solid var(--dynamic-border);
353+ cursor: pointer;
354+ transition: all 120ms ease;
355+ }
356+
357+ .action-button:hover,
358+ .action-button:focus-visible {
359+ color: var(--dynamic-text);
360+ border-color: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
361+ background: color-mix(in srgb, var(--dynamic-surface) 30%, transparent);
362+ outline: none;
363+ }
364+
365+ .kbd-hint {
366+ padding: 0.15rem 0.35rem;
367+ font-size: 0.65rem;
368+ color: var(--dynamic-muted);
369+ background: var(--dynamic-bg);
370+ border: 1px solid var(--dynamic-border);
371+ margin-left: 0.25rem;
372+ }
373+
374+ .theme-toggle {
375+ display: flex;
376+ align-items: center;
377+ justify-content: center;
378+ width: 2.25rem;
379+ height: 2.25rem;
380+ color: var(--dynamic-muted);
381+ background: transparent;
382+ border: 1px solid var(--dynamic-border);
383+ cursor: pointer;
384+ transition: all 120ms ease;
385+ }
386+
387+ .theme-toggle:hover,
388+ .theme-toggle:focus-visible {
389+ color: var(--dynamic-text);
390+ border-color: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
391+ outline: none;
392+ }
393+
394+ .theme-toggle :global(svg) {
395+ flex-shrink: 0;
396+ }
397+
398+ /* Main Content */
399+ .terminal-body {
400+ flex: 1;
401+ display: flex;
402+ flex-direction: column;
403+ min-height: 0;
404+ overflow: hidden;
405+ background: var(--dynamic-bg);
406+ }
407+
408+ .empty-shell {
409+ flex: 1;
410+ display: flex;
411+ align-items: center;
412+ justify-content: center;
413+ gap: 0.5rem;
414+ color: var(--dynamic-muted);
415+ font-size: 1.25rem;
416+ }
417+
418+ .shell-prompt {
419+ color: var(--dynamic-accent, #4ade80);
420+ }
421+
422+ .shell-cursor {
423+ animation: blink 1s step-end infinite;
424+ }
425+
426+ @keyframes blink {
427+ 50% { opacity: 0; }
428+ }
429+
430+ /* Modal Overlay */
431+ .modal-overlay {
432+ position: fixed;
433+ inset: 0;
434+ z-index: 100;
435+ display: flex;
436+ align-items: center;
437+ justify-content: center;
438+ background: rgb(0 0 0 / 0.7);
439+ backdrop-filter: blur(4px);
440+ animation: fade-in 150ms ease;
441+ }
442+
443+ @keyframes fade-in {
444+ from { opacity: 0; }
445+ }
446+
447+ .confirm-modal {
448+ width: 100%;
449+ max-width: 420px;
450+ margin: 1rem;
451+ background: var(--dynamic-surface);
452+ border: 1px solid var(--dynamic-border);
453+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
454+ animation: modal-in 200ms ease;
455+ }
456+
457+ @keyframes modal-in {
458+ from {
459+ opacity: 0;
460+ transform: scale(0.95) translateY(-8px);
461+ }
462+ }
463+
464+ .modal-header {
465+ display: flex;
466+ align-items: center;
467+ gap: 0.6rem;
468+ padding: 0.85rem 1rem;
469+ border-bottom: 1px solid var(--dynamic-border);
470+ background: color-mix(in srgb, var(--dynamic-bg) 80%, transparent);
471+ }
472+
473+ .modal-icon {
474+ font-size: 1rem;
475+ color: #fbbf24;
476+ }
477+
478+ .modal-title {
479+ font-size: 0.75rem;
480+ font-weight: 600;
481+ letter-spacing: 0.1em;
482+ color: var(--dynamic-text);
483+ }
484+
485+ .modal-message {
486+ margin: 0;
487+ padding: 1.25rem 1rem;
488+ font-size: 0.85rem;
489+ color: var(--dynamic-text);
490+ line-height: 1.6;
491+ }
492+
493+ .modal-actions {
494+ display: flex;
495+ gap: 0.5rem;
496+ padding: 0.85rem 1rem;
497+ border-top: 1px solid var(--dynamic-border);
498+ background: color-mix(in srgb, var(--dynamic-bg) 50%, transparent);
499+ }
500+
501+ .modal-btn {
502+ flex: 1;
503+ display: flex;
504+ align-items: center;
505+ justify-content: center;
506+ gap: 0.6rem;
507+ padding: 0.6rem 1rem;
508+ font-size: 0.72rem;
509+ font-weight: 500;
510+ letter-spacing: 0.08em;
511+ border: 1px solid var(--dynamic-border);
512+ cursor: pointer;
513+ transition: all 120ms ease;
514+ }
515+
516+ .modal-btn kbd {
517+ padding: 0.1rem 0.3rem;
518+ font-size: 0.6rem;
519+ background: var(--dynamic-bg);
520+ border: 1px solid var(--dynamic-border);
521+ opacity: 0.7;
522+ }
523+
524+ .modal-btn-cancel {
525+ color: var(--dynamic-muted);
526+ background: transparent;
527+ }
528+
529+ .modal-btn-cancel:hover {
530+ color: var(--dynamic-text);
531+ background: color-mix(in srgb, var(--dynamic-surface) 40%, transparent);
532+ }
533+
534+ .modal-btn-confirm {
535+ color: #fef2f2;
536+ background: #b91c1c;
537+ border-color: #991b1b;
538+ }
539+
540+ .modal-btn-confirm:hover {
541+ background: #dc2626;
542+ }
543+
544+ /* Responsive */
545+ @media (max-width: 768px) {
546+ .terminal-header {
547+ flex-wrap: wrap;
548+ gap: 0.75rem;
549+ }
550+
551+ .header-left {
552+ flex-wrap: wrap;
553+ gap: 0.75rem;
554+ }
555+
556+ .header-tabs {
557+ order: 10;
558+ width: 100%;
559+ }
560+
561+ .tab-button {
562+ flex: 1;
563+ justify-content: center;
564+ }
565+
566+ .action-button .button-text {
567+ display: none;
568+ }
569+ }
570+</style>
A gui/src/pages/homepage/shell/KeyboardHints.svelte +437 -0
@@ -0,0 +1,437 @@
1+<script lang="ts">
2+ import { homepageState } from '../state.svelte';
3+ import type { KeyboardHint } from '../keyboard';
4+
5+ // Toggle visibility with ?
6+ let showHints = $state(false);
7+ let hintTimeout: ReturnType<typeof setTimeout> | null = null;
8+
9+ // Panel-specific hints
10+ const panelHints = $derived.by((): KeyboardHint[] => {
11+ switch (homepageState.activePanel) {
12+ case 'history':
13+ return [
14+ { keys: ['d', 'x'], description: 'Delete entry' },
15+ { keys: ['D h'], description: 'Clear last hour' },
16+ { keys: ['D d'], description: 'Clear last day' },
17+ { keys: ['D w'], description: 'Clear last week' },
18+ { keys: ['D D'], description: 'Clear all history' },
19+ { keys: ['D @'], description: 'Clear domain' },
20+ { keys: ['p'], description: 'Pin to favorites' },
21+ ];
22+ case 'favorites':
23+ return [
24+ { keys: ['e'], description: 'Edit favorite' },
25+ { keys: ['t'], description: 'Manage tags' },
26+ { keys: ['m'], description: 'Move to folder' },
27+ { keys: ['1-9'], description: 'Set shortcut key' },
28+ { keys: ['d'], description: 'Delete favorite' },
29+ ];
30+ case 'analytics':
31+ return [
32+ { keys: ['r'], description: 'Refresh stats' },
33+ { keys: ['D @'], description: 'Clear domain' },
34+ ];
35+ default:
36+ return [];
37+ }
38+ });
39+
40+ // Navigation hints (always shown)
41+ const navHints: KeyboardHint[] = [
42+ { keys: ['j', '↓'], description: 'Move down' },
43+ { keys: ['k', '↑'], description: 'Move up' },
44+ { keys: ['g g'], description: 'Go to first' },
45+ { keys: ['G'], description: 'Go to last' },
46+ { keys: ['Enter', 'o'], description: 'Open item' },
47+ { keys: ['/'], description: 'Search / Command' },
48+ { keys: ['Esc'], description: 'Close / Cancel' },
49+ ];
50+
51+ // Panel switch hints
52+ const panelSwitchHints: KeyboardHint[] = [
53+ { keys: ['g h'], description: 'History panel' },
54+ { keys: ['g f'], description: 'Favorites panel' },
55+ { keys: ['g a'], description: 'Analytics panel' },
56+ ];
57+
58+ // Quick access hints
59+ const quickAccessHints: KeyboardHint[] = [
60+ { keys: ['1-9'], description: 'Quick navigate to shortcut' },
61+ ];
62+
63+ // Handle keyboard toggle
64+ function handleKeydown(e: KeyboardEvent) {
65+ if (e.key === '?' && !e.ctrlKey && !e.altKey && !e.metaKey) {
66+ // Don't trigger if in input
67+ if ((e.target as HTMLElement).tagName === 'INPUT') return;
68+ // Don't trigger if command palette is open
69+ if (homepageState.commandPaletteOpen) return;
70+
71+ e.preventDefault();
72+ toggleHints();
73+ }
74+ }
75+
76+ function toggleHints() {
77+ showHints = !showHints;
78+
79+ // Auto-hide after 10 seconds
80+ if (showHints) {
81+ if (hintTimeout) clearTimeout(hintTimeout);
82+ hintTimeout = setTimeout(() => {
83+ showHints = false;
84+ }, 10000);
85+ } else {
86+ if (hintTimeout) {
87+ clearTimeout(hintTimeout);
88+ hintTimeout = null;
89+ }
90+ }
91+ }
92+
93+ function closeHints() {
94+ showHints = false;
95+ if (hintTimeout) {
96+ clearTimeout(hintTimeout);
97+ hintTimeout = null;
98+ }
99+ }
100+
101+ // Cleanup on destroy
102+ import { onDestroy } from 'svelte';
103+ onDestroy(() => {
104+ if (hintTimeout) clearTimeout(hintTimeout);
105+ });
106+</script>
107+
108+<svelte:document onkeydown={handleKeydown} />
109+
110+{#if showHints}
111+ <div class="hints-overlay" onclick={closeHints}>
112+ <div class="hints-panel" onclick={(e) => e.stopPropagation()}>
113+ <div class="hints-header">
114+ <span class="hints-icon"></span>
115+ <span class="hints-title">KEYBOARD SHORTCUTS</span>
116+ <button class="hints-close" onclick={closeHints} type="button">
117+ <span>×</span>
118+ </button>
119+ </div>
120+
121+ <div class="hints-body">
122+ <!-- Navigation Section -->
123+ <section class="hints-section">
124+ <h3 class="section-title">
125+ <span class="section-icon"></span>
126+ Navigation
127+ </h3>
128+ <div class="hints-grid">
129+ {#each navHints as hint (hint.description)}
130+ <div class="hint-row">
131+ <div class="hint-keys">
132+ {#each hint.keys as key, i}
133+ {#if i > 0}<span class="key-sep">/</span>{/if}
134+ <kbd>{key}</kbd>
135+ {/each}
136+ </div>
137+ <span class="hint-desc">{hint.description}</span>
138+ </div>
139+ {/each}
140+ </div>
141+ </section>
142+
143+ <!-- Panel Switch Section -->
144+ <section class="hints-section">
145+ <h3 class="section-title">
146+ <span class="section-icon"></span>
147+ Panels
148+ </h3>
149+ <div class="hints-grid">
150+ {#each panelSwitchHints as hint (hint.description)}
151+ <div class="hint-row">
152+ <div class="hint-keys">
153+ {#each hint.keys as key, i}
154+ {#if i > 0}<span class="key-sep">/</span>{/if}
155+ <kbd>{key}</kbd>
156+ {/each}
157+ </div>
158+ <span class="hint-desc">{hint.description}</span>
159+ </div>
160+ {/each}
161+ </div>
162+ </section>
163+
164+ <!-- Current Panel Section -->
165+ {#if panelHints.length > 0}
166+ <section class="hints-section panel-specific">
167+ <h3 class="section-title">
168+ <span class="section-icon">
169+ {#if homepageState.activePanel === 'history'}
170+ {:else if homepageState.activePanel === 'favorites'}
171+ {:else}
172+ {/if}
173+ </span>
174+ {homepageState.activePanel.charAt(0).toUpperCase() + homepageState.activePanel.slice(1)} Actions
175+ </h3>
176+ <div class="hints-grid">
177+ {#each panelHints as hint (hint.description)}
178+ <div class="hint-row">
179+ <div class="hint-keys">
180+ {#each hint.keys as key, i}
181+ {#if i > 0}<span class="key-sep">/</span>{/if}
182+ <kbd>{key}</kbd>
183+ {/each}
184+ </div>
185+ <span class="hint-desc">{hint.description}</span>
186+ </div>
187+ {/each}
188+ </div>
189+ </section>
190+ {/if}
191+
192+ <!-- Quick Access Section -->
193+ <section class="hints-section">
194+ <h3 class="section-title">
195+ <span class="section-icon"></span>
196+ Quick Access
197+ </h3>
198+ <div class="hints-grid">
199+ {#each quickAccessHints as hint (hint.description)}
200+ <div class="hint-row">
201+ <div class="hint-keys">
202+ {#each hint.keys as key, i}
203+ {#if i > 0}<span class="key-sep">/</span>{/if}
204+ <kbd>{key}</kbd>
205+ {/each}
206+ </div>
207+ <span class="hint-desc">{hint.description}</span>
208+ </div>
209+ {/each}
210+ </div>
211+ </section>
212+ </div>
213+
214+ <div class="hints-footer">
215+ <span class="footer-hint">Press <kbd>?</kbd> to toggle hints</span>
216+ </div>
217+ </div>
218+ </div>
219+{/if}
220+
221+<style>
222+ .hints-overlay {
223+ position: fixed;
224+ inset: 0;
225+ z-index: 150;
226+ display: flex;
227+ align-items: center;
228+ justify-content: center;
229+ background: rgb(0 0 0 / 0.6);
230+ backdrop-filter: blur(4px);
231+ animation: fade-in 150ms ease;
232+ }
233+
234+ @keyframes fade-in {
235+ from { opacity: 0; }
236+ }
237+
238+ .hints-panel {
239+ width: 100%;
240+ max-width: 540px;
241+ max-height: 85vh;
242+ margin: 1rem;
243+ display: flex;
244+ flex-direction: column;
245+ background: var(--dynamic-surface);
246+ border: 1px solid var(--dynamic-border);
247+ box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
248+ animation: panel-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
249+ overflow: hidden;
250+ }
251+
252+ @keyframes panel-in {
253+ from {
254+ opacity: 0;
255+ transform: scale(0.95);
256+ }
257+ }
258+
259+ /* Header */
260+ .hints-header {
261+ display: flex;
262+ align-items: center;
263+ gap: 0.6rem;
264+ padding: 0.85rem 1rem;
265+ border-bottom: 1px solid var(--dynamic-border);
266+ background: color-mix(in srgb, var(--dynamic-bg) 80%, transparent);
267+ }
268+
269+ .hints-icon {
270+ font-size: 1rem;
271+ color: var(--dynamic-accent, #4ade80);
272+ }
273+
274+ .hints-title {
275+ flex: 1;
276+ font-size: 0.75rem;
277+ font-weight: 600;
278+ letter-spacing: 0.1em;
279+ color: var(--dynamic-text);
280+ }
281+
282+ .hints-close {
283+ display: flex;
284+ align-items: center;
285+ justify-content: center;
286+ width: 1.75rem;
287+ height: 1.75rem;
288+ font-size: 1.25rem;
289+ color: var(--dynamic-muted);
290+ background: transparent;
291+ border: 1px solid var(--dynamic-border);
292+ cursor: pointer;
293+ transition: all 120ms ease;
294+ }
295+
296+ .hints-close:hover {
297+ color: var(--dynamic-text);
298+ border-color: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
299+ }
300+
301+ /* Body */
302+ .hints-body {
303+ flex: 1;
304+ overflow-y: auto;
305+ padding: 1rem;
306+ display: flex;
307+ flex-direction: column;
308+ gap: 1.25rem;
309+ }
310+
311+ .hints-section {
312+ display: flex;
313+ flex-direction: column;
314+ gap: 0.6rem;
315+ }
316+
317+ .hints-section.panel-specific {
318+ padding: 0.75rem;
319+ background: color-mix(in srgb, var(--dynamic-accent, #4ade80) 8%, transparent);
320+ border: 1px solid color-mix(in srgb, var(--dynamic-accent, #4ade80) 25%, var(--dynamic-border) 75%);
321+ }
322+
323+ .section-title {
324+ display: flex;
325+ align-items: center;
326+ gap: 0.5rem;
327+ margin: 0;
328+ font-size: 0.72rem;
329+ font-weight: 600;
330+ text-transform: uppercase;
331+ letter-spacing: 0.1em;
332+ color: var(--dynamic-text);
333+ }
334+
335+ .section-icon {
336+ font-size: 0.85rem;
337+ color: var(--dynamic-muted);
338+ }
339+
340+ .panel-specific .section-icon {
341+ color: var(--dynamic-accent, #4ade80);
342+ }
343+
344+ .hints-grid {
345+ display: grid;
346+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
347+ gap: 0.4rem;
348+ }
349+
350+ .hint-row {
351+ display: flex;
352+ align-items: center;
353+ gap: 0.6rem;
354+ padding: 0.4rem 0.6rem;
355+ background: color-mix(in srgb, var(--dynamic-bg) 60%, transparent);
356+ border: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
357+ }
358+
359+ .hint-keys {
360+ display: flex;
361+ align-items: center;
362+ gap: 0.2rem;
363+ flex-shrink: 0;
364+ }
365+
366+ .hint-keys kbd {
367+ padding: 0.2rem 0.4rem;
368+ font-size: 0.68rem;
369+ font-family: inherit;
370+ color: var(--dynamic-text);
371+ background: var(--dynamic-bg);
372+ border: 1px solid var(--dynamic-border);
373+ min-width: 1.4rem;
374+ text-align: center;
375+ }
376+
377+ .key-sep {
378+ font-size: 0.6rem;
379+ color: var(--dynamic-muted);
380+ opacity: 0.6;
381+ }
382+
383+ .hint-desc {
384+ font-size: 0.72rem;
385+ color: var(--dynamic-muted);
386+ white-space: nowrap;
387+ overflow: hidden;
388+ text-overflow: ellipsis;
389+ }
390+
391+ /* Footer */
392+ .hints-footer {
393+ display: flex;
394+ justify-content: center;
395+ padding: 0.65rem 1rem;
396+ border-top: 1px solid var(--dynamic-border);
397+ background: color-mix(in srgb, var(--dynamic-bg) 70%, transparent);
398+ }
399+
400+ .footer-hint {
401+ font-size: 0.68rem;
402+ color: var(--dynamic-muted);
403+ letter-spacing: 0.05em;
404+ }
405+
406+ .footer-hint kbd {
407+ padding: 0.15rem 0.35rem;
408+ font-size: 0.62rem;
409+ background: var(--dynamic-bg);
410+ border: 1px solid var(--dynamic-border);
411+ margin: 0 0.25rem;
412+ }
413+
414+ /* Scrollbar */
415+ .hints-body::-webkit-scrollbar {
416+ width: 6px;
417+ }
418+
419+ .hints-body::-webkit-scrollbar-track {
420+ background: transparent;
421+ }
422+
423+ .hints-body::-webkit-scrollbar-thumb {
424+ background: var(--dynamic-border);
425+ }
426+
427+ /* Responsive */
428+ @media (max-width: 480px) {
429+ .hints-grid {
430+ grid-template-columns: 1fr;
431+ }
432+
433+ .hints-panel {
434+ max-height: 90vh;
435+ }
436+ }
437+</style>
A gui/src/pages/homepage/shell/StatusBar.svelte +407 -0
@@ -0,0 +1,407 @@
1+<script lang="ts">
2+ import { homepageState } from '../state.svelte';
3+ import { History, Star, BarChart3, Terminal, Search, Folder, Tags, Command } from '@lucide/svelte';
4+
5+ // Derived status indicators
6+ const panelLabel = $derived({
7+ history: 'HISTORY',
8+ favorites: 'FAVORITES',
9+ analytics: 'ANALYTICS',
10+ }[homepageState.activePanel]);
11+
12+ const panelIcon = $derived({
13+ history: History,
14+ favorites: Star,
15+ analytics: BarChart3,
16+ }[homepageState.activePanel]);
17+
18+ // Loading states
19+ const isLoading = $derived(
20+ homepageState.historyLoading ||
21+ homepageState.favoritesLoading ||
22+ homepageState.analyticsLoading
23+ );
24+
25+ // Current list stats
26+ const listStats = $derived.by(() => {
27+ switch (homepageState.activePanel) {
28+ case 'history':
29+ if (homepageState.historySearchQuery) {
30+ return {
31+ count: homepageState.historySearchResults.length,
32+ label: 'results',
33+ filtered: true,
34+ };
35+ }
36+ return {
37+ count: homepageState.history.length,
38+ label: 'entries',
39+ filtered: false,
40+ };
41+ case 'favorites':
42+ const filtered = homepageState.filteredFavorites;
43+ const total = homepageState.favorites.length;
44+ return {
45+ count: filtered.length,
46+ label: filtered.length === total ? 'items' : `of ${total}`,
47+ filtered: filtered.length !== total,
48+ };
49+ case 'analytics':
50+ return {
51+ count: homepageState.domainStats.length,
52+ label: 'domains',
53+ filtered: false,
54+ };
55+ default:
56+ return { count: 0, label: 'items', filtered: false };
57+ }
58+ });
59+
60+ // Position in list
61+ const positionInfo = $derived.by(() => {
62+ const length = homepageState.currentListLength;
63+ if (length === 0) return null;
64+
65+ const pos = homepageState.focusedIndex + 1;
66+ return { current: pos, total: length };
67+ });
68+
69+ // Mode indicator
70+ const modeIndicator = $derived.by(() => {
71+ if (homepageState.commandPaletteOpen) {
72+ return { icon: Terminal, label: 'COMMAND', color: 'accent' };
73+ }
74+ if (homepageState.historySearchQuery) {
75+ return { icon: Search, label: 'SEARCH', color: 'warning' };
76+ }
77+ if (homepageState.selectedFolderId !== null) {
78+ const folder = homepageState.folders.find(f => f.id === homepageState.selectedFolderId);
79+ return { icon: Folder, label: folder?.name || 'FOLDER', color: 'info' };
80+ }
81+ if (homepageState.selectedTagIds.length > 0) {
82+ return { icon: Tags, label: `${homepageState.selectedTagIds.length} TAGS`, color: 'info' };
83+ }
84+ return null;
85+ });
86+</script>
87+
88+<footer class="status-bar">
89+ <!-- Left Section: Mode & Panel -->
90+ <div class="status-left">
91+ <!-- Pending Key Prefix Indicator -->
92+ {#if homepageState.pendingKeyPrefix}
93+ <div class="status-chip prefix-chip" data-color="warning">
94+ <span class="chip-icon">
95+ <Command size={12} strokeWidth={2} />
96+ </span>
97+ <kbd class="prefix-key">{homepageState.pendingKeyPrefix}</kbd>
98+ <span class="prefix-waiting">...</span>
99+ </div>
100+ {/if}
101+
102+ <!-- Mode Indicator -->
103+ {#if modeIndicator}
104+ <div class="status-chip mode-chip" data-color={modeIndicator.color}>
105+ <span class="chip-icon">
106+ <svelte:component this={modeIndicator.icon} size={12} strokeWidth={2} />
107+ </span>
108+ <span class="chip-text">{modeIndicator.label}</span>
109+ </div>
110+ {/if}
111+
112+ <!-- Panel Indicator -->
113+ <div class="status-chip panel-chip">
114+ <span class="chip-icon">
115+ {#if panelIcon}
116+ <svelte:component this={panelIcon} size={12} strokeWidth={2} />
117+ {/if}
118+ </span>
119+ <span class="chip-text">{panelLabel}</span>
120+ </div>
121+
122+ <!-- Loading Indicator -->
123+ {#if isLoading}
124+ <div class="status-chip loading-chip">
125+ <span class="loading-spinner"></span>
126+ <span class="chip-text">SYNC</span>
127+ </div>
128+ {/if}
129+ </div>
130+
131+ <!-- Center Section: Stats -->
132+ <div class="status-center">
133+ <div class="stats-group">
134+ <span class="stats-count" class:filtered={listStats.filtered}>
135+ {listStats.count}
136+ </span>
137+ <span class="stats-label">{listStats.label}</span>
138+ </div>
139+
140+ {#if positionInfo}
141+ <div class="position-group">
142+ <span class="position-current">{positionInfo.current}</span>
143+ <span class="position-sep">/</span>
144+ <span class="position-total">{positionInfo.total}</span>
145+ </div>
146+ {/if}
147+ </div>
148+
149+ <!-- Right Section: Quick Hints -->
150+ <div class="status-right">
151+ <div class="hint-item">
152+ <kbd>/</kbd>
153+ <span>cmd</span>
154+ </div>
155+ <div class="hint-item">
156+ <kbd>j</kbd><kbd>k</kbd>
157+ <span>nav</span>
158+ </div>
159+ <div class="hint-item">
160+ <kbd>?</kbd>
161+ <span>help</span>
162+ </div>
163+ </div>
164+</footer>
165+
166+<style>
167+ .status-bar {
168+ display: flex;
169+ align-items: center;
170+ justify-content: space-between;
171+ gap: 1rem;
172+ padding: 0.5rem 0.85rem;
173+ font-size: 0.68rem;
174+ font-family: 'JetBrains Mono NF', ui-monospace, monospace;
175+ letter-spacing: 0.05em;
176+ border-top: 1px solid var(--dynamic-border);
177+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
178+ color: var(--dynamic-muted);
179+ }
180+
181+ /* Sections */
182+ .status-left,
183+ .status-center,
184+ .status-right {
185+ display: flex;
186+ align-items: center;
187+ gap: 0.6rem;
188+ }
189+
190+ .status-left {
191+ flex: 1;
192+ justify-content: flex-start;
193+ }
194+
195+ .status-center {
196+ flex: 0 0 auto;
197+ gap: 1rem;
198+ }
199+
200+ .status-right {
201+ flex: 1;
202+ justify-content: flex-end;
203+ gap: 0.85rem;
204+ }
205+
206+ /* Status Chips */
207+ .status-chip {
208+ display: inline-flex;
209+ align-items: center;
210+ gap: 0.35rem;
211+ padding: 0.25rem 0.5rem;
212+ background: color-mix(in srgb, var(--dynamic-surface) 60%, transparent);
213+ border: 1px solid var(--dynamic-border);
214+ }
215+
216+ .chip-icon {
217+ display: flex;
218+ align-items: center;
219+ }
220+
221+ .chip-text {
222+ font-weight: 500;
223+ text-transform: uppercase;
224+ }
225+
226+ /* Mode chip colors */
227+ .mode-chip[data-color="accent"] {
228+ background: color-mix(in srgb, var(--dynamic-accent, #4ade80) 15%, transparent);
229+ border-color: color-mix(in srgb, var(--dynamic-accent, #4ade80) 40%, var(--dynamic-border) 60%);
230+ color: var(--dynamic-accent, #4ade80);
231+ }
232+
233+ .mode-chip[data-color="warning"] {
234+ background: color-mix(in srgb, #fbbf24 15%, transparent);
235+ border-color: color-mix(in srgb, #fbbf24 40%, var(--dynamic-border) 60%);
236+ color: #fbbf24;
237+ }
238+
239+ .mode-chip[data-color="info"] {
240+ background: color-mix(in srgb, #60a5fa 15%, transparent);
241+ border-color: color-mix(in srgb, #60a5fa 40%, var(--dynamic-border) 60%);
242+ color: #60a5fa;
243+ }
244+
245+ /* Pending prefix indicator */
246+ .prefix-chip {
247+ animation: pulse-border 1s ease-in-out infinite;
248+ }
249+
250+ .prefix-chip[data-color="warning"] {
251+ background: color-mix(in srgb, #fbbf24 15%, transparent);
252+ border-color: color-mix(in srgb, #fbbf24 50%, var(--dynamic-border) 50%);
253+ color: #fbbf24;
254+ }
255+
256+ .prefix-key {
257+ font-size: 0.7rem;
258+ font-weight: 700;
259+ padding: 0.1rem 0.25rem;
260+ background: color-mix(in srgb, #fbbf24 25%, transparent);
261+ border: 1px solid color-mix(in srgb, #fbbf24 40%, var(--dynamic-border) 60%);
262+ font-family: 'JetBrains Mono NF', ui-monospace, monospace;
263+ }
264+
265+ .prefix-waiting {
266+ font-size: 0.65rem;
267+ opacity: 0.8;
268+ animation: blink 0.6s ease-in-out infinite;
269+ }
270+
271+ @keyframes pulse-border {
272+ 0%, 100% { border-color: color-mix(in srgb, #fbbf24 50%, var(--dynamic-border) 50%); }
273+ 50% { border-color: #fbbf24; }
274+ }
275+
276+ @keyframes blink {
277+ 0%, 100% { opacity: 0.4; }
278+ 50% { opacity: 1; }
279+ }
280+
281+ .panel-chip {
282+ color: var(--dynamic-text);
283+ }
284+
285+ .panel-chip .chip-icon {
286+ color: var(--dynamic-accent, #4ade80);
287+ }
288+
289+ /* Loading */
290+ .loading-chip {
291+ color: var(--dynamic-muted);
292+ }
293+
294+ .loading-spinner {
295+ width: 0.7rem;
296+ height: 0.7rem;
297+ border: 1.5px solid var(--dynamic-border);
298+ border-top-color: var(--dynamic-accent, #4ade80);
299+ border-radius: 50%;
300+ animation: spin 0.8s linear infinite;
301+ }
302+
303+ @keyframes spin {
304+ to { transform: rotate(360deg); }
305+ }
306+
307+ /* Stats */
308+ .stats-group {
309+ display: flex;
310+ align-items: baseline;
311+ gap: 0.35rem;
312+ }
313+
314+ .stats-count {
315+ font-weight: 600;
316+ color: var(--dynamic-text);
317+ font-variant-numeric: tabular-nums;
318+ }
319+
320+ .stats-count.filtered {
321+ color: var(--dynamic-accent, #4ade80);
322+ }
323+
324+ .stats-label {
325+ color: var(--dynamic-muted);
326+ }
327+
328+ .position-group {
329+ display: flex;
330+ align-items: center;
331+ gap: 0.15rem;
332+ padding: 0.2rem 0.45rem;
333+ background: var(--dynamic-bg);
334+ border: 1px solid var(--dynamic-border);
335+ font-variant-numeric: tabular-nums;
336+ }
337+
338+ .position-current {
339+ font-weight: 600;
340+ color: var(--dynamic-text);
341+ }
342+
343+ .position-sep {
344+ color: var(--dynamic-border);
345+ }
346+
347+ .position-total {
348+ color: var(--dynamic-muted);
349+ }
350+
351+ /* Hints */
352+ .hint-item {
353+ display: flex;
354+ align-items: center;
355+ gap: 0.25rem;
356+ color: var(--dynamic-muted);
357+ }
358+
359+ .hint-item kbd {
360+ padding: 0.1rem 0.3rem;
361+ font-size: 0.6rem;
362+ background: var(--dynamic-bg);
363+ border: 1px solid var(--dynamic-border);
364+ color: var(--dynamic-text);
365+ }
366+
367+ .hint-item span {
368+ font-size: 0.62rem;
369+ text-transform: uppercase;
370+ letter-spacing: 0.08em;
371+ }
372+
373+ /* Responsive */
374+ @media (max-width: 640px) {
375+ .status-bar {
376+ flex-wrap: wrap;
377+ gap: 0.5rem;
378+ }
379+
380+ .status-left,
381+ .status-right {
382+ flex: 0 0 auto;
383+ }
384+
385+ .status-center {
386+ order: -1;
387+ width: 100%;
388+ justify-content: center;
389+ padding-bottom: 0.35rem;
390+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
391+ }
392+
393+ .hint-item span {
394+ display: none;
395+ }
396+ }
397+
398+ @media (max-width: 480px) {
399+ .status-right {
400+ display: none;
401+ }
402+
403+ .status-left {
404+ flex: 1;
405+ }
406+ }
407+</style>
A gui/src/pages/homepage/shell/index.ts +5 -0
@@ -0,0 +1,5 @@
1+// Shell Components - Industrial Terminal Layout
2+export { default as HomepageShell } from './HomepageShell.svelte';
3+export { default as CommandPalette } from './CommandPalette.svelte';
4+export { default as StatusBar } from './StatusBar.svelte';
5+export { default as KeyboardHints } from './KeyboardHints.svelte';
A gui/src/pages/homepage/state.svelte.ts +502 -0
@@ -0,0 +1,502 @@
1+// ═══════════════════════════════════════════════════════════════════════════════
2+// Homepage Global State (Svelte 5 Runes)
3+// Single module-level $state with exported store object
4+// ═══════════════════════════════════════════════════════════════════════════════
5+
6+import type {
7+ HistoryEntry,
8+ TimelineGroup,
9+ DomainStat,
10+ DailyVisitCount,
11+ HourlyDistribution,
12+ Favorite,
13+ Folder,
14+ Tag,
15+ Analytics,
16+ PanelType,
17+ HistoryCleanupRange,
18+} from './types';
19+
20+// ═══════════════════════════════════════════════════════════════════════════════
21+// HISTORY STATE
22+// ═══════════════════════════════════════════════════════════════════════════════
23+
24+let history = $state<HistoryEntry[]>([]);
25+let timeline = $state<TimelineGroup[]>([]);
26+let historyLoading = $state(true);
27+let historyOffset = $state(0);
28+let hasMoreHistory = $state(true);
29+let historySearchQuery = $state('');
30+let historySearchResults = $state<HistoryEntry[]>([]);
31+let historySearching = $state(false);
32+
33+// ═══════════════════════════════════════════════════════════════════════════════
34+// FAVORITES STATE
35+// ═══════════════════════════════════════════════════════════════════════════════
36+
37+let favorites = $state<Favorite[]>([]);
38+let folders = $state<Folder[]>([]);
39+let tags = $state<Tag[]>([]);
40+let favoritesLoading = $state(true);
41+let selectedFolderId = $state<number | null>(null);
42+let selectedTagIds = $state<number[]>([]);
43+let editingFavorite = $state<Favorite | null>(null);
44+
45+// ═══════════════════════════════════════════════════════════════════════════════
46+// ANALYTICS STATE
47+// ═══════════════════════════════════════════════════════════════════════════════
48+
49+let analytics = $state<Analytics | null>(null);
50+let analyticsLoading = $state(true);
51+let domainStats = $state<DomainStat[]>([]);
52+let dailyVisits = $state<DailyVisitCount[]>([]);
53+let hourlyDistribution = $state<HourlyDistribution[]>([]);
54+
55+// ═══════════════════════════════════════════════════════════════════════════════
56+// UI STATE
57+// ═══════════════════════════════════════════════════════════════════════════════
58+
59+let activePanel = $state<PanelType>('history');
60+let commandPaletteOpen = $state(false);
61+let commandPaletteQuery = $state('');
62+let focusedIndex = $state(-1); // -1 means no item focused (focus on search input)
63+let confirmModalOpen = $state(false);
64+let confirmModalMessage = $state('');
65+let confirmModalAction = $state<(() => void) | null>(null);
66+let cleanupModalOpen = $state(false);
67+let cleanupRange = $state<HistoryCleanupRange>('hour');
68+let pendingKeyPrefix = $state<string | null>(null);
69+
70+// ═══════════════════════════════════════════════════════════════════════════════
71+// HELPER FUNCTIONS
72+// ═══════════════════════════════════════════════════════════════════════════════
73+
74+function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
75+ const groups = new Map<string, HistoryEntry[]>();
76+ const today = new Date();
77+ today.setHours(0, 0, 0, 0);
78+ const yesterday = new Date(today);
79+ yesterday.setDate(yesterday.getDate() - 1);
80+
81+ for (const entry of entries) {
82+ const date = new Date(entry.last_visited);
83+ const dateKey = date.toISOString().split('T')[0] ?? '';
84+ if (!groups.has(dateKey)) {
85+ groups.set(dateKey, []);
86+ }
87+ groups.get(dateKey)!.push(entry);
88+ }
89+
90+ const result: TimelineGroup[] = [];
91+ for (const [dateKey, items] of groups) {
92+ const date = new Date(dateKey);
93+ let label: string;
94+
95+ if (date >= today) {
96+ label = 'Today';
97+ } else if (date >= yesterday) {
98+ label = 'Yesterday';
99+ } else {
100+ label = date.toLocaleDateString(undefined, {
101+ weekday: 'short',
102+ month: 'short',
103+ day: 'numeric',
104+ });
105+ }
106+
107+ result.push({ date: dateKey, label, entries: items });
108+ }
109+
110+ return result.sort((a, b) => b.date.localeCompare(a.date));
111+}
112+
113+// ═══════════════════════════════════════════════════════════════════════════════
114+// EXPORTED STORE
115+// ═══════════════════════════════════════════════════════════════════════════════
116+
117+export const homepageState = {
118+ // ─────────────────────────────────────────────────────────────────────────────
119+ // History Getters
120+ // ─────────────────────────────────────────────────────────────────────────────
121+ get history() { return history; },
122+ get timeline() { return timeline; },
123+ get historyLoading() { return historyLoading; },
124+ get historyOffset() { return historyOffset; },
125+ get hasMoreHistory() { return hasMoreHistory; },
126+ get historySearchQuery() { return historySearchQuery; },
127+ get historySearchResults() { return historySearchResults; },
128+ get historySearching() { return historySearching; },
129+
130+ // ─────────────────────────────────────────────────────────────────────────────
131+ // Favorites Getters
132+ // ─────────────────────────────────────────────────────────────────────────────
133+ get favorites() { return favorites; },
134+ get folders() { return folders; },
135+ get tags() { return tags; },
136+ get favoritesLoading() { return favoritesLoading; },
137+ get selectedFolderId() { return selectedFolderId; },
138+ get selectedTagIds() { return selectedTagIds; },
139+ get editingFavorite() { return editingFavorite; },
140+
141+ // ─────────────────────────────────────────────────────────────────────────────
142+ // Analytics Getters
143+ // ─────────────────────────────────────────────────────────────────────────────
144+ get analytics() { return analytics; },
145+ get analyticsLoading() { return analyticsLoading; },
146+ get domainStats() { return domainStats; },
147+ get dailyVisits() { return dailyVisits; },
148+ get hourlyDistribution() { return hourlyDistribution; },
149+
150+ // ─────────────────────────────────────────────────────────────────────────────
151+ // UI Getters
152+ // ─────────────────────────────────────────────────────────────────────────────
153+ get activePanel() { return activePanel; },
154+ get commandPaletteOpen() { return commandPaletteOpen; },
155+ get commandPaletteQuery() { return commandPaletteQuery; },
156+ get focusedIndex() { return focusedIndex; },
157+ get confirmModalOpen() { return confirmModalOpen; },
158+ get confirmModalMessage() { return confirmModalMessage; },
159+ get cleanupModalOpen() { return cleanupModalOpen; },
160+ get cleanupRange() { return cleanupRange; },
161+ get pendingKeyPrefix() { return pendingKeyPrefix; },
162+
163+ // ─────────────────────────────────────────────────────────────────────────────
164+ // Computed Properties
165+ // ─────────────────────────────────────────────────────────────────────────────
166+ get filteredFavorites(): Favorite[] {
167+ let result = favorites;
168+
169+ // Filter by folder
170+ if (selectedFolderId !== null) {
171+ result = result.filter(f => f.folder_id === selectedFolderId);
172+ }
173+
174+ // Filter by tags
175+ if (selectedTagIds.length > 0) {
176+ result = result.filter(f =>
177+ f.tags?.some(t => selectedTagIds.includes(t.id))
178+ );
179+ }
180+
181+ return result;
182+ },
183+
184+ get currentList(): HistoryEntry[] | Favorite[] {
185+ switch (activePanel) {
186+ case 'history':
187+ return historySearchQuery ? historySearchResults : history;
188+ case 'favorites':
189+ return this.filteredFavorites;
190+ default:
191+ return [];
192+ }
193+ },
194+
195+ get currentListLength(): number {
196+ return this.currentList.length;
197+ },
198+
199+ // ─────────────────────────────────────────────────────────────────────────────
200+ // History Actions
201+ // ─────────────────────────────────────────────────────────────────────────────
202+ setHistory(items: HistoryEntry[]) {
203+ history = items;
204+ timeline = groupHistoryByDate(items);
205+ },
206+
207+ appendHistory(items: HistoryEntry[]) {
208+ history = [...history, ...items];
209+ timeline = groupHistoryByDate(history);
210+ },
211+
212+ setHistoryLoading(loading: boolean) {
213+ historyLoading = loading;
214+ },
215+
216+ setHistoryOffset(offset: number) {
217+ historyOffset = offset;
218+ },
219+
220+ setHasMoreHistory(hasMore: boolean) {
221+ hasMoreHistory = hasMore;
222+ },
223+
224+ deleteHistoryEntry(id: number) {
225+ history = history.filter(h => h.id !== id);
226+ timeline = groupHistoryByDate(history);
227+ historySearchResults = historySearchResults.filter(h => h.id !== id);
228+ },
229+
230+ clearHistory() {
231+ history = [];
232+ timeline = [];
233+ historySearchResults = [];
234+ historyOffset = 0;
235+ hasMoreHistory = true;
236+ },
237+
238+ setHistorySearchQuery(q: string) {
239+ historySearchQuery = q;
240+ if (!q) {
241+ historySearchResults = [];
242+ }
243+ },
244+
245+ setHistorySearchResults(results: HistoryEntry[]) {
246+ historySearchResults = results;
247+ },
248+
249+ setHistorySearching(searching: boolean) {
250+ historySearching = searching;
251+ },
252+
253+ // ─────────────────────────────────────────────────────────────────────────────
254+ // Favorites Actions
255+ // ─────────────────────────────────────────────────────────────────────────────
256+ setFavorites(items: Favorite[]) {
257+ favorites = items;
258+ },
259+
260+ setFolders(items: Folder[]) {
261+ folders = items;
262+ },
263+
264+ setTags(items: Tag[]) {
265+ tags = items;
266+ },
267+
268+ setFavoritesLoading(loading: boolean) {
269+ favoritesLoading = loading;
270+ },
271+
272+ selectFolder(id: number | null) {
273+ selectedFolderId = id;
274+ focusedIndex = 0;
275+ },
276+
277+ toggleTag(id: number) {
278+ if (selectedTagIds.includes(id)) {
279+ selectedTagIds = selectedTagIds.filter(t => t !== id);
280+ } else {
281+ selectedTagIds = [...selectedTagIds, id];
282+ }
283+ focusedIndex = 0;
284+ },
285+
286+ clearTagSelection() {
287+ selectedTagIds = [];
288+ focusedIndex = 0;
289+ },
290+
291+ setEditingFavorite(fav: Favorite | null) {
292+ editingFavorite = fav;
293+ },
294+
295+ updateFavorite(updated: Favorite) {
296+ favorites = favorites.map(f => f.id === updated.id ? updated : f);
297+ },
298+
299+ deleteFavorite(id: number) {
300+ favorites = favorites.filter(f => f.id !== id);
301+ },
302+
303+ addFolder(folder: Folder) {
304+ folders = [...folders, folder];
305+ },
306+
307+ updateFolder(updated: Folder) {
308+ folders = folders.map(f => f.id === updated.id ? updated : f);
309+ },
310+
311+ deleteFolder(id: number) {
312+ folders = folders.filter(f => f.id !== id);
313+ // Clear folder selection if deleted folder was selected
314+ if (selectedFolderId === id) {
315+ selectedFolderId = null;
316+ }
317+ // Update favorites that were in this folder
318+ favorites = favorites.map(f =>
319+ f.folder_id === id ? { ...f, folder_id: null } : f
320+ );
321+ },
322+
323+ addTag(tag: Tag) {
324+ tags = [...tags, tag];
325+ },
326+
327+ updateTag(updated: Tag) {
328+ tags = tags.map(t => t.id === updated.id ? updated : t);
329+ },
330+
331+ deleteTag(id: number) {
332+ tags = tags.filter(t => t.id !== id);
333+ selectedTagIds = selectedTagIds.filter(t => t !== id);
334+ // Remove tag from all favorites
335+ favorites = favorites.map(f => ({
336+ ...f,
337+ tags: f.tags?.filter(t => t.id !== id),
338+ }));
339+ },
340+
341+ // ─────────────────────────────────────────────────────────────────────────────
342+ // Analytics Actions
343+ // ─────────────────────────────────────────────────────────────────────────────
344+ setAnalytics(data: Analytics | null) {
345+ analytics = data;
346+ if (data) {
347+ domainStats = data.domain_stats;
348+ dailyVisits = data.daily_visits;
349+ hourlyDistribution = data.hourly_distribution;
350+ }
351+ },
352+
353+ setAnalyticsLoading(loading: boolean) {
354+ analyticsLoading = loading;
355+ },
356+
357+ setDomainStats(stats: DomainStat[]) {
358+ domainStats = stats;
359+ },
360+
361+ // ─────────────────────────────────────────────────────────────────────────────
362+ // UI Actions
363+ // ─────────────────────────────────────────────────────────────────────────────
364+ setActivePanel(panel: PanelType) {
365+ activePanel = panel;
366+ focusedIndex = -1; // Clear focus, let search input be focused
367+ // Clear search when switching panels
368+ historySearchQuery = '';
369+ historySearchResults = [];
370+ },
371+
372+ openCommandPalette() {
373+ commandPaletteOpen = true;
374+ commandPaletteQuery = '';
375+ },
376+
377+ closeCommandPalette() {
378+ commandPaletteOpen = false;
379+ commandPaletteQuery = '';
380+ },
381+
382+ setCommandPaletteQuery(q: string) {
383+ commandPaletteQuery = q;
384+ },
385+
386+ setFocusedIndex(i: number) {
387+ focusedIndex = Math.max(-1, i);
388+ },
389+
390+ focusNext() {
391+ const maxIndex = this.currentListLength - 1;
392+ if (maxIndex < 0) return;
393+ // If unfocused (-1), go to first item
394+ if (focusedIndex === -1) {
395+ focusedIndex = 0;
396+ } else {
397+ focusedIndex = Math.min(focusedIndex + 1, maxIndex);
398+ }
399+ },
400+
401+ focusPrev() {
402+ // If at first item or unfocused, stay at -1 (return to search)
403+ if (focusedIndex <= 0) {
404+ focusedIndex = -1;
405+ } else {
406+ focusedIndex = focusedIndex - 1;
407+ }
408+ },
409+
410+ focusFirst() {
411+ focusedIndex = 0;
412+ },
413+
414+ focusLast() {
415+ focusedIndex = Math.max(0, this.currentListLength - 1);
416+ },
417+
418+ clearFocus() {
419+ focusedIndex = -1;
420+ },
421+
422+ // Confirmation modal
423+ showConfirm(message: string, action: () => void) {
424+ confirmModalMessage = message;
425+ confirmModalAction = action;
426+ confirmModalOpen = true;
427+ },
428+
429+ confirmAction() {
430+ confirmModalAction?.();
431+ confirmModalOpen = false;
432+ confirmModalAction = null;
433+ confirmModalMessage = '';
434+ },
435+
436+ cancelConfirm() {
437+ confirmModalOpen = false;
438+ confirmModalAction = null;
439+ confirmModalMessage = '';
440+ },
441+
442+ // Cleanup modal
443+ openCleanupModal() {
444+ cleanupModalOpen = true;
445+ cleanupRange = 'hour';
446+ },
447+
448+ closeCleanupModal() {
449+ cleanupModalOpen = false;
450+ },
451+
452+ setCleanupRange(range: HistoryCleanupRange) {
453+ cleanupRange = range;
454+ },
455+
456+ // Keyboard prefix state (for 'g' and 'D' prefix indicators)
457+ setPendingKeyPrefix(prefix: string | null) {
458+ pendingKeyPrefix = prefix;
459+ },
460+
461+ // ─────────────────────────────────────────────────────────────────────────────
462+ // Reset
463+ // ─────────────────────────────────────────────────────────────────────────────
464+ reset() {
465+ // History
466+ history = [];
467+ timeline = [];
468+ historyLoading = true;
469+ historyOffset = 0;
470+ hasMoreHistory = true;
471+ historySearchQuery = '';
472+ historySearchResults = [];
473+ historySearching = false;
474+
475+ // Favorites
476+ favorites = [];
477+ folders = [];
478+ tags = [];
479+ favoritesLoading = true;
480+ selectedFolderId = null;
481+ selectedTagIds = [];
482+ editingFavorite = null;
483+
484+ // Analytics
485+ analytics = null;
486+ analyticsLoading = true;
487+ domainStats = [];
488+ dailyVisits = [];
489+ hourlyDistribution = [];
490+
491+ // UI
492+ activePanel = 'history';
493+ commandPaletteOpen = false;
494+ commandPaletteQuery = '';
495+ focusedIndex = -1;
496+ confirmModalOpen = false;
497+ confirmModalMessage = '';
498+ confirmModalAction = null;
499+ cleanupModalOpen = false;
500+ cleanupRange = 'hour';
501+ },
502+};
A gui/src/pages/homepage/types.ts +227 -0
@@ -0,0 +1,227 @@
1+// ═══════════════════════════════════════════════════════════════════════════════
2+// Homepage TypeScript Types
3+// Mirrors Go models from internal/db/models.go and related query files
4+// ═══════════════════════════════════════════════════════════════════════════════
5+
6+// ─────────────────────────────────────────────────────────────────────────────
7+// History Types
8+// ─────────────────────────────────────────────────────────────────────────────
9+
10+export interface HistoryEntry {
11+ id: number;
12+ url: string;
13+ title: string | null;
14+ favicon_url: string | null;
15+ visit_count: number;
16+ last_visited: string;
17+ created_at: string;
18+}
19+
20+export interface TimelineGroup {
21+ date: string; // YYYY-MM-DD
22+ label: string; // "Today", "Yesterday", "Dec 5", etc.
23+ entries: HistoryEntry[];
24+}
25+
26+export interface HistoryStats {
27+ total_entries: number;
28+ total_visits: number;
29+ unique_days: number;
30+}
31+
32+export interface DomainStat {
33+ domain: string;
34+ page_count: number;
35+ total_visits: number;
36+ last_visit: string;
37+}
38+
39+export interface DailyVisitCount {
40+ day: string;
41+ entries: number;
42+ visits: number;
43+}
44+
45+export interface HourlyDistribution {
46+ hour: number;
47+ visit_count: number;
48+}
49+
50+// ─────────────────────────────────────────────────────────────────────────────
51+// Favorites Types
52+// ─────────────────────────────────────────────────────────────────────────────
53+
54+export interface Favorite {
55+ id: number;
56+ url: string;
57+ title: string | null;
58+ favicon_url: string | null;
59+ position: number;
60+ created_at: string;
61+ updated_at: string;
62+ folder_id: number | null;
63+ shortcut_key: number | null; // 1-9
64+ tags?: Tag[]; // Populated via join
65+}
66+
67+export interface Folder {
68+ id: number;
69+ name: string;
70+ icon: string | null;
71+ position: number;
72+ created_at: string;
73+}
74+
75+export interface Tag {
76+ id: number;
77+ name: string;
78+ color: string; // Hex color like #6b7280
79+ created_at: string;
80+}
81+
82+export interface TagAssignment {
83+ favorite_id: number;
84+ tag_id: number;
85+}
86+
87+// ─────────────────────────────────────────────────────────────────────────────
88+// Analytics Types
89+// ─────────────────────────────────────────────────────────────────────────────
90+
91+export interface Analytics {
92+ stats: HistoryStats;
93+ domain_stats: DomainStat[];
94+ daily_visits: DailyVisitCount[];
95+ hourly_distribution: HourlyDistribution[];
96+}
97+
98+// ─────────────────────────────────────────────────────────────────────────────
99+// UI State Types
100+// ─────────────────────────────────────────────────────────────────────────────
101+
102+export type PanelType = 'history' | 'favorites' | 'analytics';
103+
104+export type HistoryCleanupRange = 'hour' | 'day' | 'week' | 'month' | 'all';
105+
106+export interface CommandPaletteItem {
107+ id: string;
108+ label: string;
109+ description?: string;
110+ icon?: import('svelte').Component | string;
111+ action: () => void;
112+ shortcut?: string;
113+}
114+
115+export interface KeyboardShortcut {
116+ key: string;
117+ description: string;
118+ action: () => void;
119+ context?: 'global' | 'history' | 'favorites' | 'command-palette';
120+}
121+
122+// ─────────────────────────────────────────────────────────────────────────────
123+// Message Types (WebKit Bridge)
124+// ─────────────────────────────────────────────────────────────────────────────
125+
126+export type MessageType =
127+ // Folders
128+ | 'folder_list'
129+ | 'folder_create'
130+ | 'folder_update'
131+ | 'folder_delete'
132+ // Tags
133+ | 'tag_list'
134+ | 'tag_create'
135+ | 'tag_update'
136+ | 'tag_delete'
137+ | 'tag_assign'
138+ | 'tag_remove'
139+ // History
140+ | 'history_timeline'
141+ | 'history_search_fts'
142+ | 'history_analytics'
143+ | 'history_domain_stats'
144+ | 'history_delete_entry'
145+ | 'history_delete_range'
146+ | 'history_clear_all'
147+ | 'history_delete_domain'
148+ // Favorites
149+ | 'favorite_list'
150+ | 'favorite_set_shortcut'
151+ | 'favorite_get_by_shortcut'
152+ | 'favorite_set_folder';
153+
154+export interface Message<T = unknown> {
155+ type: MessageType;
156+ requestId?: string;
157+ payload?: T;
158+}
159+
160+export interface MessageResponse<T = unknown> {
161+ success: boolean;
162+ data?: T;
163+ error?: string;
164+}
165+
166+// ─────────────────────────────────────────────────────────────────────────────
167+// Request/Response Payloads
168+// ─────────────────────────────────────────────────────────────────────────────
169+
170+// History
171+export interface HistoryTimelineRequest {
172+ limit?: number;
173+ offset?: number;
174+}
175+
176+export interface HistorySearchRequest {
177+ query: string;
178+ limit?: number;
179+}
180+
181+export interface HistoryDeleteRangeRequest {
182+ range: HistoryCleanupRange;
183+}
184+
185+export interface HistoryDeleteDomainRequest {
186+ domain: string;
187+}
188+
189+// Folders
190+export interface FolderCreateRequest {
191+ name: string;
192+ icon?: string;
193+}
194+
195+export interface FolderUpdateRequest {
196+ id: number;
197+ name: string;
198+ icon?: string;
199+}
200+
201+// Tags
202+export interface TagCreateRequest {
203+ name: string;
204+ color?: string;
205+}
206+
207+export interface TagUpdateRequest {
208+ id: number;
209+ name?: string;
210+ color?: string;
211+}
212+
213+export interface TagAssignRequest {
214+ favorite_id: number;
215+ tag_id: number;
216+}
217+
218+// Favorites
219+export interface FavoriteShortcutRequest {
220+ favorite_id: number;
221+ shortcut_key: number; // 1-9
222+}
223+
224+export interface FavoriteFolderRequest {
225+ favorite_id: number;
226+ folder_id: number | null;
227+}
A gui/src/styles/design-tokens.css +170 -0
@@ -0,0 +1,170 @@
1+/*
2+ * Design Tokens - shadcn-svelte inspired
3+ *
4+ * Uses OKLCH color space for perceptually uniform colors.
5+ * These tokens define the visual language of the browser UI.
6+ *
7+ * Aesthetic: Industrial Terminal / Brutalist Minimal
8+ * - Sharp corners (--radius: 0)
9+ * - High contrast
10+ * - Vibrant green primary accent
11+ * - Cyan secondary accent
12+ */
13+
14+:root {
15+ /* Radius - sharp terminal aesthetic */
16+ --radius: 0;
17+ --radius-sm: 0;
18+ --radius-md: 0;
19+ --radius-lg: 0;
20+ --radius-xl: 0;
21+
22+ /* ═══════════════════════════════════════════════════════════════
23+ * LIGHT THEME
24+ * ═══════════════════════════════════════════════════════════════ */
25+
26+ /* Core surfaces */
27+ --background: oklch(0.985 0.002 260);
28+ --foreground: oklch(0.145 0.02 260);
29+
30+ /* Card/Panel surfaces */
31+ --card: oklch(0.965 0.004 260);
32+ --card-foreground: oklch(0.145 0.02 260);
33+
34+ /* Popover/Dropdown */
35+ --popover: oklch(0.985 0.002 260);
36+ --popover-foreground: oklch(0.145 0.02 260);
37+
38+ /* Primary - Vibrant Green */
39+ --primary: oklch(0.55 0.20 145);
40+ --primary-foreground: oklch(0.985 0 0);
41+
42+ /* Secondary - Muted surfaces */
43+ --secondary: oklch(0.94 0.008 260);
44+ --secondary-foreground: oklch(0.20 0.02 260);
45+
46+ /* Muted - Subdued content */
47+ --muted: oklch(0.94 0.008 260);
48+ --muted-foreground: oklch(0.48 0.02 260);
49+
50+ /* Accent - Cyan for info/highlights */
51+ --accent: oklch(0.58 0.14 200);
52+ --accent-foreground: oklch(0.145 0.02 260);
53+
54+ /* Destructive - Red for danger */
55+ --destructive: oklch(0.55 0.22 25);
56+ --destructive-foreground: oklch(0.985 0 0);
57+
58+ /* Borders & Inputs */
59+ --border: oklch(0.88 0.008 260);
60+ --input: oklch(0.88 0.008 260);
61+ --ring: oklch(0.55 0.20 145);
62+
63+ /* Chart colors for analytics */
64+ --chart-1: oklch(0.58 0.18 145); /* Green */
65+ --chart-2: oklch(0.56 0.14 200); /* Cyan */
66+ --chart-3: oklch(0.66 0.15 70); /* Amber */
67+ --chart-4: oklch(0.58 0.18 300); /* Purple */
68+ --chart-5: oklch(0.60 0.18 25); /* Red-orange */
69+
70+ /* Sidebar (if used) */
71+ --sidebar: oklch(0.975 0.002 260);
72+ --sidebar-foreground: oklch(0.145 0.02 260);
73+ --sidebar-primary: oklch(0.55 0.20 145);
74+ --sidebar-primary-foreground: oklch(0.985 0 0);
75+ --sidebar-accent: oklch(0.94 0.008 260);
76+ --sidebar-accent-foreground: oklch(0.20 0.02 260);
77+ --sidebar-border: oklch(0.88 0.008 260);
78+ --sidebar-ring: oklch(0.55 0.20 145);
79+}
80+
81+.dark {
82+ /* ═══════════════════════════════════════════════════════════════
83+ * DARK THEME - High Contrast
84+ * ═══════════════════════════════════════════════════════════════ */
85+
86+ /* Core surfaces - true black for maximum contrast */
87+ --background: oklch(0.09 0.005 260);
88+ --foreground: oklch(0.97 0 0);
89+
90+ /* Card/Panel surfaces - slightly elevated */
91+ --card: oklch(0.14 0.008 260);
92+ --card-foreground: oklch(0.97 0 0);
93+
94+ /* Popover/Dropdown */
95+ --popover: oklch(0.16 0.008 260);
96+ --popover-foreground: oklch(0.97 0 0);
97+
98+ /* Primary - Bright Green */
99+ --primary: oklch(0.75 0.19 145);
100+ --primary-foreground: oklch(0.12 0.02 145);
101+
102+ /* Secondary - Elevated surfaces */
103+ --secondary: oklch(0.20 0.008 260);
104+ --secondary-foreground: oklch(0.97 0 0);
105+
106+ /* Muted - More visible subdued content */
107+ --muted: oklch(0.22 0.008 260);
108+ --muted-foreground: oklch(0.70 0.01 260);
109+
110+ /* Accent - Cyan for info/highlights */
111+ --accent: oklch(0.68 0.14 200);
112+ --accent-foreground: oklch(0.97 0 0);
113+
114+ /* Destructive - Red for danger */
115+ --destructive: oklch(0.65 0.20 25);
116+ --destructive-foreground: oklch(0.97 0 0);
117+
118+ /* Borders & Inputs - more visible */
119+ --border: oklch(0.35 0.01 260);
120+ --input: oklch(0.35 0.01 260);
121+ --ring: oklch(0.75 0.19 145);
122+
123+ /* Chart colors for analytics - brighter for dark bg */
124+ --chart-1: oklch(0.72 0.18 145); /* Green */
125+ --chart-2: oklch(0.68 0.14 200); /* Cyan */
126+ --chart-3: oklch(0.76 0.16 70); /* Amber */
127+ --chart-4: oklch(0.66 0.18 300); /* Purple */
128+ --chart-5: oklch(0.68 0.18 25); /* Red-orange */
129+
130+ /* Sidebar (if used) */
131+ --sidebar: oklch(0.12 0.008 260);
132+ --sidebar-foreground: oklch(0.97 0 0);
133+ --sidebar-primary: oklch(0.75 0.19 145);
134+ --sidebar-primary-foreground: oklch(0.12 0.02 145);
135+ --sidebar-accent: oklch(0.20 0.008 260);
136+ --sidebar-accent-foreground: oklch(0.97 0 0);
137+ --sidebar-border: oklch(0.35 0.01 260);
138+ --sidebar-ring: oklch(0.75 0.19 145);
139+}
140+
141+/* ═══════════════════════════════════════════════════════════════
142+ * TAILWIND THEME BRIDGE
143+ * Maps design tokens to Tailwind's color system
144+ * ═══════════════════════════════════════════════════════════════ */
145+@theme inline {
146+ --color-background: var(--background);
147+ --color-foreground: var(--foreground);
148+ --color-card: var(--card);
149+ --color-card-foreground: var(--card-foreground);
150+ --color-popover: var(--popover);
151+ --color-popover-foreground: var(--popover-foreground);
152+ --color-primary: var(--primary);
153+ --color-primary-foreground: var(--primary-foreground);
154+ --color-secondary: var(--secondary);
155+ --color-secondary-foreground: var(--secondary-foreground);
156+ --color-muted: var(--muted);
157+ --color-muted-foreground: var(--muted-foreground);
158+ --color-accent: var(--accent);
159+ --color-accent-foreground: var(--accent-foreground);
160+ --color-destructive: var(--destructive);
161+ --color-destructive-foreground: var(--destructive-foreground);
162+ --color-border: var(--border);
163+ --color-input: var(--input);
164+ --color-ring: var(--ring);
165+ --color-chart-1: var(--chart-1);
166+ --color-chart-2: var(--chart-2);
167+ --color-chart-3: var(--chart-3);
168+ --color-chart-4: var(--chart-4);
169+ --color-chart-5: var(--chart-5);
170+}
M gui/src/styles/tailwind.css +72 -8
@@ -1,14 +1,78 @@
1+@import "./design-tokens.css";
1 @import "tailwindcss";2 @import "tailwindcss";
2 3
3-/* Dynamic theme switching via CSS variables */4+/* Inter - Sans-serif for UI text */
5+@font-face {
6+ font-family: "Inter";
7+ src: url("dumb://app/fonts/Inter-Regular.woff2") format("woff2");
8+ font-weight: 400;
9+ font-style: normal;
10+ font-display: swap;
11+}
12+
13+@font-face {
14+ font-family: "Inter";
15+ src: url("dumb://app/fonts/Inter-Medium.woff2") format("woff2");
16+ font-weight: 500;
17+ font-style: normal;
18+ font-display: swap;
19+}
20+
21+@font-face {
22+ font-family: "Inter";
23+ src: url("dumb://app/fonts/Inter-Bold.woff2") format("woff2");
24+ font-weight: 700;
25+ font-style: normal;
26+ font-display: swap;
27+}
28+
29+/* JetBrains Mono Nerd Font - Industrial Terminal aesthetic */
30+@font-face {
31+ font-family: "JetBrains Mono NF";
32+ src: url("dumb://app/fonts/JetBrainsMonoNerdFont-Regular.woff2") format("woff2");
33+ font-weight: 400;
34+ font-style: normal;
35+ font-display: swap;
36+}
37+
38+@font-face {
39+ font-family: "JetBrains Mono NF";
40+ src: url("dumb://app/fonts/JetBrainsMonoNerdFont-Bold.woff2") format("woff2");
41+ font-weight: 700;
42+ font-style: normal;
43+ font-display: swap;
44+}
45+
46+@font-face {
47+ font-family: "JetBrains Mono NF";
48+ src: url("dumb://app/fonts/JetBrainsMonoNerdFont-Italic.woff2") format("woff2");
49+ font-weight: 400;
50+ font-style: italic;
51+ font-display: swap;
52+}
53+
54+/* Dynamic theme switching via CSS variables
55+ * These bridge between:
56+ * 1. Backend palette (--color-browser-*) injected at runtime
57+ * 2. Design tokens (--background, --primary, etc.) for static fallbacks
58+ * 3. Component usage (--dynamic-*) for actual styling
59+ */
4 :root {60 :root {
5- --dynamic-bg: var(--color-browser-bg);61+ /* Primary bridge - uses backend palette when available, falls back to tokens */
6- --dynamic-surface-variant: var(--color-browser-surface-variant);62+ --dynamic-bg: var(--color-browser-bg, var(--background));
7- --dynamic-surface: var(--color-browser-surface);63+ --dynamic-surface: var(--color-browser-surface, var(--card));
8- --dynamic-text: var(--color-browser-text);64+ --dynamic-surface-variant: var(--color-browser-surface-variant, var(--secondary));
9- --dynamic-muted: var(--color-browser-muted);65+ --dynamic-text: var(--color-browser-text, var(--foreground));
10- --dynamic-accent: var(--color-browser-accent);66+ --dynamic-muted: var(--color-browser-muted, var(--muted-foreground));
11- --dynamic-border: var(--color-browser-border);67+ --dynamic-accent: var(--color-browser-accent, var(--primary));
68+ --dynamic-border: var(--color-browser-border, var(--border));
69+
70+ /* Extended semantic colors from design tokens */
71+ --dynamic-primary: var(--primary);
72+ --dynamic-primary-foreground: var(--primary-foreground);
73+ --dynamic-secondary: var(--secondary);
74+ --dynamic-destructive: var(--destructive);
75+ --dynamic-ring: var(--ring);
12 }76 }
13 77
14 /* Base styles for browser UI components */78 /* Base styles for browser UI components */
M gui/tailwind.config.js +5 -0
@@ -3,6 +3,11 @@ export default {
3 content: ["./src/**/*.{html,js,ts,svelte}"],3 content: ["./src/**/*.{html,js,ts,svelte}"],
4 theme: {4 theme: {
5 extend: {5 extend: {
6+ // Font families for Industrial Terminal aesthetic
7+ fontFamily: {
8+ mono: ['"JetBrains Mono NF"', "ui-monospace", "SFMono-Regular", "monospace"],
9+ sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
10+ },
6 // Custom design system colors11 // Custom design system colors
7 colors: {12 colors: {
8 // Browser-specific colors that work well in both themes13 // Browser-specific colors that work well in both themes
A gui/vite.config.blocked.ts +46 -0
@@ -0,0 +1,46 @@
1+import { defineConfig } from "vite";
2+import { svelte } from "@sveltejs/vite-plugin-svelte";
3+import { resolve } from "path";
4+import { pageGenerator } from "./vite-plugin-pages";
5+
6+export default defineConfig({
7+ plugins: [
8+ svelte({
9+ emitCss: false, // Inline component CSS for shadow DOM compatibility
10+ }),
11+ pageGenerator([
12+ {
13+ name: "blocked",
14+ title: "Page Blocked",
15+ script: "blocked.min.js",
16+ filename: "blocked.html",
17+ },
18+ ]),
19+ ],
20+ build: {
21+ rollupOptions: {
22+ input: resolve(__dirname, "src/pages/blocked.ts"),
23+ output: {
24+ dir: "../assets/gui",
25+ entryFileNames: "blocked.min.js",
26+ chunkFileNames: "[name].js",
27+ assetFileNames: "[name].[ext]",
28+ manualChunks: undefined,
29+ format: "iife",
30+ name: "DumberBlocked",
31+ },
32+ },
33+ emptyOutDir: false,
34+ target: ["es2020", "chrome91", "firefox90"],
35+ minify: true,
36+ sourcemap: false,
37+ cssCodeSplit: false,
38+ assetsInlineLimit: 4096,
39+ },
40+ resolve: {
41+ alias: {
42+ $lib: resolve(__dirname, "src/lib"),
43+ $components: resolve(__dirname, "src/components"),
44+ },
45+ },
46+});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close