1<script lang="ts">
2 import { onMount } from 'svelte';
3 // Icons disabled for investigation: removed @lucide/svelte imports
4
5 interface HistoryItem {
6 id: number;
7 url: string;
8 title: string;
9 favicon_url?: string;
10 visit_count: number;
11 last_visited: string;
12 created_at: string;
13 }
14
15 interface Shortcut {
16 key: string;
17 description: string;
18 action: () => void;
19 }
20
21 interface HistoryStats {
22 total_entries: number;
23 recent_count: number;
24 oldest_entry?: string;
25 newest_entry?: string;
26 }
27
28 let historyItems = $state<HistoryItem[]>([]);
29 let topVisited = $state<HistoryItem[]>([]);
30 let shortcuts = $state<Shortcut[]>([]);
31 let stats = $state<HistoryStats | null>(null);
32 let historyLoading = $state(true);
33 let topVisitedLoading = $state(true);
34 let shortcutsLoading = $state(true);
35 let statsLoading = $state(true);
36 let deletingIds = $state<number[]>([]);
37 let historyOffset = $state(0);
38 let hasMoreHistory = $state(true);
39 let loadingMoreHistory = $state(false);
40
41 // Function for checking if an item is being deleted
42 const isDeleting = (id: number) => deletingIds.includes(id);
43
44 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
45 const initializeShortcuts = () => {
46 shortcuts = [
47 {
48 key: 'Ctrl+L',
49 description: 'Focus address bar',
50 action: () => console.log('Ctrl+L triggered')
51 },
52 {
53 key: 'Ctrl+F',
54 description: 'Find in page',
55 action: () => console.log('Ctrl+F triggered')
56 },
57 {
58 key: 'Ctrl+Shift+C',
59 description: 'Copy URL',
60 action: () => console.log('Ctrl+Shift+C triggered')
61 },
62 {
63 key: 'Ctrl+R',
64 description: 'Reload page',
65 action: () => window.location.reload()
66 },
67 {
68 key: 'Ctrl+Shift+R',
69 description: 'Hard reload',
70 action: () => console.log('Ctrl+Shift+R triggered')
71 },
72 {
73 key: 'F5',
74 description: 'Reload page',
75 action: () => window.location.reload()
76 },
77 {
78 key: 'F12',
79 description: 'DevTools',
80 action: () => console.log('F12 triggered')
81 },
82 {
83 key: 'Alt+←',
84 description: 'Navigate back',
85 action: () => console.log('Alt+← triggered')
86 },
87 {
88 key: 'Alt+→',
89 description: 'Navigate forward',
90 action: () => console.log('Alt+→ triggered')
91 },
92 {
93 key: 'Ctrl+0',
94 description: 'Reset zoom',
95 action: () => console.log('Ctrl+0 triggered')
96 },
97 {
98 key: 'Ctrl++',
99 description: 'Zoom in',
100 action: () => console.log('Ctrl++ triggered')
101 },
102 {
103 key: 'Ctrl+-',
104 description: 'Zoom out',
105 action: () => console.log('Ctrl+- triggered')
106 }
107 ];
108 shortcutsLoading = false;
109 };
110
111 // Setup message bridge callbacks (once)
112 let callbacksInitialized = false;
113 const pendingRequests = new Map();
114
115 const initializeCallbacks = () => {
116 if (callbacksInitialized) return;
117
118 (window as any).__dumber_history_recent = (data: any[], requestId?: string) => {
119 const request = pendingRequests.get(requestId || 'default');
120 if (request) {
121 request.resolve(data);
122 pendingRequests.delete(requestId || 'default');
123 }
124 };
125
126 (window as any).__dumber_history_error = (error: string, requestId?: string) => {
127 const request = pendingRequests.get(requestId || 'default');
128 if (request) {
129 request.reject(new Error(error));
130 pendingRequests.delete(requestId || 'default');
131 }
132 };
133
134 // Omnibox suggestions callback - forward to injected script via DOM event bridge
135 let _pendingSuggestions: any[] | null = null;
136 (window as any).__dumber_omnibox_suggestions = (suggestions: any[]) => {
137 try {
138 console.log('🎯 [HOMEPAGE] Received omnibox suggestions:', suggestions?.length || 0, 'items');
139 // Dispatch an event that injected-world listener can consume
140 const evt = new CustomEvent('dumber:omnibox-suggestions', {
141 detail: { suggestions }
142 });
143 document.dispatchEvent(evt);
144 _pendingSuggestions = null; // clear cache on successful dispatch
145 } catch (err) {
146 console.warn('⚠️ [HOMEPAGE] Failed to dispatch suggestions event, caching:', err);
147 _pendingSuggestions = suggestions;
148 }
149 };
150
151 // If omnibox gets ready later, re-send any pending suggestions
152 document.addEventListener('dumber:omnibox-ready', () => {
153 if (_pendingSuggestions && Array.isArray(_pendingSuggestions)) {
154 try {
155 const evt = new CustomEvent('dumber:omnibox-suggestions', {
156 detail: { suggestions: _pendingSuggestions }
157 });
158 document.dispatchEvent(evt);
159 _pendingSuggestions = null;
160 console.log('🔄 [HOMEPAGE] Re-dispatched pending suggestions after omnibox-ready');
161 } catch (e) {
162 console.warn('⚠️ [HOMEPAGE] Failed to re-dispatch pending suggestions:', e);
163 }
164 }
165 });
166
167 callbacksInitialized = true;
168 };
169
170 const sendHistoryRequest = (type: string, params: any): Promise<any[]> => {
171 initializeCallbacks();
172
173 const requestId = `${type}_${Date.now()}_${Math.random()}`;
174
175 return new Promise((resolve, reject) => {
176 pendingRequests.set(requestId, { resolve, reject });
177
178 // Timeout after 10 seconds
179 setTimeout(() => {
180 if (pendingRequests.has(requestId)) {
181 pendingRequests.delete(requestId);
182 reject(new Error('Request timeout'));
183 }
184 }, 10000);
185
186 try {
187 const bridge = (window as any).webkit?.messageHandlers?.dumber;
188 if (bridge && typeof bridge.postMessage === 'function') {
189 bridge.postMessage(JSON.stringify({
190 ...params,
191 type,
192 requestId
193 }));
194 } else {
195 reject(new Error('WebKit message handler not available'));
196 }
197 } catch (error) {
198 pendingRequests.delete(requestId);
199 reject(error);
200 }
201 });
202 };
203
204 // Fetch recent history via message bridge
205 const fetchHistory = async () => {
206 try {
207 const data = await sendHistoryRequest('history_recent', {
208 limit: 20,
209 offset: 0
210 });
211
212 historyItems = Array.isArray(data) ? data : [];
213 historyOffset = historyItems.length;
214 hasMoreHistory = data.length === 20;
215 } catch (error) {
216 console.error('Error fetching history:', error);
217 historyItems = [];
218 hasMoreHistory = false;
219 } finally {
220 historyLoading = false;
221 }
222 };
223
224 // Load more history items for infinite scroll
225 const loadMoreHistory = async () => {
226 if (loadingMoreHistory || !hasMoreHistory) return;
227
228 loadingMoreHistory = true;
229 try {
230 const data = await sendHistoryRequest('history_recent', {
231 limit: 20,
232 offset: historyOffset
233 });
234
235 if (Array.isArray(data) && data.length > 0) {
236 historyItems = [...historyItems, ...data];
237 historyOffset += data.length;
238 hasMoreHistory = data.length === 20; // If we got full batch, there might be more
239 } else {
240 hasMoreHistory = false;
241 }
242 } catch (error) {
243 console.error('Error fetching more history:', error);
244 hasMoreHistory = false;
245 } finally {
246 loadingMoreHistory = false;
247 }
248 };
249
250 // Navigate to a URL when history item is clicked
251 const navigateTo = (url: string) => {
252 window.location.href = url;
253 };
254
255 // Delete a history entry
256 const deleteHistoryEntry = async (id: number, event: Event) => {
257 event.stopPropagation(); // Prevent navigation
258
259 // Add to deleting array for animation
260 if (!deletingIds.includes(id)) {
261 deletingIds = [...deletingIds, id];
262 }
263
264 try {
265 // Set up response handlers
266 (window as any).__dumber_history_deleted = async () => {
267 // Wait a bit for the fade animation to complete
268 await new Promise(resolve => setTimeout(resolve, 200));
269
270 // Reset pagination and refresh history after successful deletion
271 historyOffset = 0;
272 hasMoreHistory = true;
273 await fetchHistory();
274 await fetchStats();
275 await fetchTopVisited();
276
277 // Remove from deleting array after refresh
278 deletingIds = deletingIds.filter(i => i !== id);
279 };
280
281 (window as any).__dumber_history_error = (error: string) => {
282 console.error('Error deleting history entry:', error);
283 // Remove from deleting array if failed
284 deletingIds = deletingIds.filter(i => i !== id);
285 };
286
287 // Send message to Go backend
288 const bridge = (window as any).webkit?.messageHandlers?.dumber;
289 if (bridge && typeof bridge.postMessage === 'function') {
290 bridge.postMessage(JSON.stringify({
291 type: 'history_delete',
292 historyId: id.toString()
293 }));
294 } else {
295 throw new Error('WebKit message handler not available');
296 }
297 } catch (error) {
298 console.error('Error sending delete request:', error);
299 // Remove from deleting array if failed
300 deletingIds = deletingIds.filter(i => i !== id);
301 }
302 };
303
304 // Get domain from URL
305 const getDomain = (url: string): string => {
306 try {
307 return new URL(url).hostname;
308 } catch {
309 return url;
310 }
311 };
312
313 // Format relative time
314 const formatTime = (timestamp: string): string => {
315 try {
316 const date = new Date(timestamp);
317 const now = new Date();
318 const diff = now.getTime() - date.getTime();
319
320 const minutes = Math.floor(diff / (1000 * 60));
321 const hours = Math.floor(diff / (1000 * 60 * 60));
322 const days = Math.floor(diff / (1000 * 60 * 60 * 24));
323
324 if (minutes < 1) return 'Just now';
325 if (minutes < 60) return `${minutes}m ago`;
326 if (hours < 24) return `${hours}h ago`;
327 if (days < 7) return `${days}d ago`;
328
329 return date.toLocaleDateString();
330 } catch {
331 return '';
332 }
333 };
334
335 // Fetch history stats via message bridge
336 const fetchStats = async () => {
337 try {
338 // Set up response handler
339 (window as any).__dumber_history_stats = (data: any) => {
340 stats = data;
341 statsLoading = false;
342 };
343
344 (window as any).__dumber_history_error = (error: string) => {
345 console.error('Error fetching stats:', error);
346 stats = null;
347 statsLoading = false;
348 };
349
350 // Send message to Go backend
351 const bridge = (window as any).webkit?.messageHandlers?.dumber;
352 if (bridge && typeof bridge.postMessage === 'function') {
353 bridge.postMessage(JSON.stringify({
354 type: 'history_stats'
355 }));
356 } else {
357 throw new Error('WebKit message handler not available');
358 }
359 } catch (error) {
360 console.error('Error sending stats request:', error);
361 stats = null;
362 statsLoading = false;
363 }
364 };
365
366 // Fetch top visited sites via message bridge
367 const fetchTopVisited = async () => {
368 try {
369 // Set up response handler
370 (window as any).__dumber_history_search = (data: any[]) => {
371 topVisited = Array.isArray(data) ? data.slice(0, 5) : [];
372 topVisitedLoading = false;
373 };
374
375 (window as any).__dumber_history_error = (error: string) => {
376 console.error('Error fetching top visited:', error);
377 topVisited = [];
378 topVisitedLoading = false;
379 };
380
381 // Send message to Go backend
382 const bridge = (window as any).webkit?.messageHandlers?.dumber;
383 if (bridge && typeof bridge.postMessage === 'function') {
384 bridge.postMessage(JSON.stringify({
385 type: 'history_search',
386 q: '',
387 limit: 5
388 }));
389 } else {
390 throw new Error('WebKit message handler not available');
391 }
392 } catch (error) {
393 console.error('Error sending top visited request:', error);
394 topVisited = [];
395 topVisitedLoading = false;
396 }
397 };
398
399 // Intersection Observer for infinite scroll
400 let historyScrollSentinel = $state<HTMLElement | null>(null);
401
402 const setupInfiniteScroll = () => {
403 if (!historyScrollSentinel) return;
404
405 const observer = new IntersectionObserver(
406 (entries) => {
407 const entry = entries[0];
408 if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
409 loadMoreHistory();
410 }
411 },
412 {
413 threshold: 0.1,
414 rootMargin: '50px'
415 }
416 );
417
418 observer.observe(historyScrollSentinel);
419
420 return () => observer.disconnect();
421 };
422
423 onMount(() => {
424 initializeShortcuts();
425 fetchHistory().then(() => {
426 // Setup infinite scroll after initial history is loaded
427 setTimeout(setupInfiniteScroll, 100);
428 });
429 fetchStats();
430 fetchTopVisited();
431 });
432
433</script>
434
435<div class="homepage-container">
436<div class="container">
437 <div class="history-section">
438 <h2 class="section-title">Recent History</h2>
439 <div class="history-list">
440 {#if historyLoading}
441 <div class="loading">Loading history...</div>
442 {:else if historyItems.length === 0}
443 <div class="empty-state">
444 <h3>No history yet</h3>
445 <p>Start browsing to see your recent history here.</p>
446 </div>
447 {:else}
448 {#each historyItems as item (item.url)}
449 <div
450 class="history-item"
451 class:deleting={isDeleting(item.id)}
452 role="button"
453 tabindex="0"
454 onclick={() => navigateTo(item.url)}
455 onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
456 >
457 <div class="history-line">
458 <div class="history-favicon-chip">
459 {#if item.favicon_url}
460 <img
461 src={item.favicon_url}
462 alt=""
463 class="history-favicon-img"
464 onerror={(e) => {
465 const target = e.target as HTMLImageElement;
466 if (target) {
467 target.style.display='none';
468 const fallback = target.nextElementSibling as HTMLElement;
469 if (fallback) fallback.style.display='block';
470 }
471 }}
472 onload={(e) => {
473 const target = e.target as HTMLImageElement;
474 if (target) {
475 const fallback = target.nextElementSibling as HTMLElement;
476 if (fallback) fallback.style.display='none';
477 }
478 }}
479 />
480 <div class="history-favicon-fallback" style="display: none;">
481 <!-- inline globe placeholder -->
482 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
483 <circle cx="12" cy="12" r="10" />
484 <path d="M2 12h20" />
485 </svg>
486 </div>
487 {:else}
488 <div class="history-favicon-fallback">
489 <!-- inline globe placeholder -->
490 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
491 <circle cx="12" cy="12" r="10" />
492 <path d="M2 12h20" />
493 </svg>
494 </div>
495 {/if}
496 </div>
497 <div class="history-title">{item.title || 'Untitled'}</div>
498 <div class="history-sep">•</div>
499 <div class="history-domain">{getDomain(item.url)}</div>
500 <div class="history-sep">•</div>
501 <div class="history-url">{item.url}</div>
502 <div class="history-time">{formatTime(item.last_visited)}</div>
503 <div
504 class="history-delete"
505 role="button"
506 tabindex="0"
507 onclick={(e) => deleteHistoryEntry(item.id, e)}
508 onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
509 title="Delete this entry"
510 >
511 <!-- inline delete placeholder -->
512 <span aria-hidden="true" style="display:inline-block;line-height:1;">×</span>
513 </div>
514 </div>
515 </div>
516 {/each}
517
518 <!-- Infinite scroll sentinel and loading indicator -->
519 {#if hasMoreHistory}
520 <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
521 {#if loadingMoreHistory}
522 <div class="loading-more">Loading more history...</div>
523 {/if}
524 {/if}
525 {/if}
526 </div>
527 </div>
528
529 <div class="shortcuts-section">
530 <h2 class="section-title">Keyboard Shortcuts</h2>
531 <div class="shortcuts-container">
532 {#if shortcutsLoading}
533 <div class="loading">Loading shortcuts...</div>
534 {:else}
535 <table class="shortcuts-table">
536 <tbody>
537 {#each shortcuts as shortcut (shortcut.key)}
538 <tr class="shortcut-row">
539 <td class="shortcut-key">{shortcut.key}</td>
540 <td class="shortcut-desc">{shortcut.description}</td>
541 </tr>
542 {/each}
543 </tbody>
544 </table>
545 {/if}
546 </div>
547
548 <h2 class="section-title stats-title">Statistics</h2>
549 <div class="stats-container">
550 {#if statsLoading}
551 <div class="loading">Loading stats...</div>
552 {:else if stats}
553 <div class="stats-grid">
554 <div class="stat-item">
555 <div class="stat-value">{stats.total_entries}</div>
556 <div class="stat-label">History Entries</div>
557 </div>
558 </div>
559 {:else}
560 <div class="empty-state">
561 <p>No statistics available</p>
562 </div>
563 {/if}
564 </div>
565
566 <h2 class="section-title top-visited-title">Top 5 Most Visited</h2>
567 <div class="top-visited-container">
568 {#if topVisitedLoading}
569 <div class="loading">Loading top visited...</div>
570 {:else if topVisited.length === 0}
571 <div class="empty-state">
572 <p>No visit data available</p>
573 </div>
574 {:else}
575 <div class="top-visited-list">
576 {#each topVisited as item, index (item.url)}
577 <div
578 class="top-visited-item"
579 role="button"
580 tabindex="0"
581 onclick={() => navigateTo(item.url)}
582 onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
583 >
584 <div class="rank">{index + 1}</div>
585 <div class="site-info">
586 <div class="site-title">{item.title || 'Untitled'}</div>
587 <div class="site-domain">{getDomain(item.url)}</div>
588 </div>
589 <div class="visit-count">{item.visit_count}</div>
590 </div>
591 {/each}
592 </div>
593 {/if}
594 </div>
595 </div>
596</div>
597</div>
598
599<style>
600 /* Remove default browser margins/padding on the page */
601 :global(html), :global(body) {
602 margin: 0;
603 padding: 0;
604 background: #1a1a1a;
605 }
606
607 /* Scoped homepage container to prevent global CSS conflicts */
608 .homepage-container {
609 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
610 background: #1a1a1a;
611 color: #ffffff;
612 min-height: 100vh;
613 overflow-x: hidden;
614 display: flex;
615 flex-direction: column;
616 margin: 0;
617 padding: 0;
618 box-sizing: border-box;
619 }
620
621 /* Reset styles for children, but scoped to homepage only */
622 .homepage-container * {
623 margin: 0;
624 padding: 0;
625 box-sizing: border-box;
626 }
627
628 .container {
629 flex: 1;
630 display: flex;
631 max-width: 1200px;
632 margin: 0 auto;
633 width: 100%;
634 padding: 2rem;
635 gap: 2rem;
636 overflow-x: hidden;
637 }
638
639 .history-section {
640 flex: 2;
641 min-height: 0;
642 min-width: 0;
643 }
644
645 .shortcuts-section {
646 flex: 1;
647 min-height: 0;
648 min-width: 0;
649 }
650
651 .section-title {
652 font-size: 1.5rem;
653 margin-bottom: 1rem;
654 color: #ffffff;
655 border-bottom: 2px solid #404040;
656 padding-bottom: 0.5rem;
657 }
658
659 .history-list {
660 overflow-y: auto;
661 overflow-x: hidden;
662 max-height: calc(100vh - 8rem);
663 max-width: 100%;
664 }
665
666 .history-item {
667 padding: 0.75rem;
668 margin-bottom: 0.5rem;
669 background: #2d2d2d;
670 border-radius: 6px;
671 cursor: pointer;
672 transition: all 0.2s ease-in-out;
673 border-left: 3px solid #404040;
674 overflow: hidden;
675 max-width: 100%;
676 position: relative;
677 opacity: 1;
678 transform: translateX(0);
679 }
680
681 .history-item:hover {
682 background: #3d3d3d;
683 border-left-color: #0066cc;
684 }
685
686 .history-item:hover .history-delete {
687 opacity: 1;
688 visibility: visible;
689 }
690
691 .history-item.deleting {
692 opacity: 0;
693 transform: translateX(-20px);
694 pointer-events: none;
695 }
696
697 .history-line {
698 display: flex;
699 gap: 0.5rem;
700 white-space: nowrap;
701 align-items: center;
702 overflow: hidden;
703 width: 100%;
704 min-width: 0;
705 }
706
707 .history-favicon-chip {
708 flex: 0 0 20px;
709 width: 20px;
710 height: 20px;
711 border-radius: 50%;
712 background: #ccc;
713 border: 1px solid rgba(0,0,0,.12);
714 box-shadow: 0 1px 2px rgba(0,0,0,.12);
715 display: flex;
716 align-items: center;
717 justify-content: center;
718 }
719
720 .history-favicon-img {
721 width: 16px;
722 height: 16px;
723 filter: brightness(1.06) contrast(1.03);
724 image-rendering: -webkit-optimize-contrast;
725 }
726
727 .history-favicon-fallback {
728 font-size: 12px;
729 display: flex;
730 align-items: center;
731 justify-content: center;
732 color: #888;
733 }
734
735 .history-title {
736 font-size: 0.95rem;
737 color: #e6e6e6;
738 flex: 0 0 auto;
739 }
740
741 .history-domain {
742 font-size: 0.9rem;
743 color: #a5a5a5;
744 flex: 0 0 auto;
745 }
746
747 .history-sep {
748 color: #666;
749 flex: 0 0 auto;
750 }
751
752 .history-url {
753 font-size: 0.9rem;
754 color: #7a7a7a;
755 flex: 1 1 auto;
756 min-width: 0;
757 overflow: hidden;
758 text-overflow: ellipsis;
759 -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
760 mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
761 }
762
763 .history-time {
764 font-size: 0.85rem;
765 color: #666;
766 flex: 0 0 auto;
767 margin-left: auto;
768 }
769
770 .history-delete {
771 position: absolute;
772 right: 0.75rem;
773 top: 50%;
774 transform: translateY(-50%);
775 opacity: 0;
776 visibility: hidden;
777 transition: opacity 0.2s, visibility 0.2s, transform 0.2s;
778 cursor: pointer;
779 background: #1a1a1a;
780 border-radius: 3px;
781 padding: 0.25rem;
782 border: 1px solid #404040;
783 display: flex;
784 align-items: center;
785 justify-content: center;
786 width: 2rem;
787 height: 2rem;
788 color: #888;
789 }
790
791 .history-delete:hover {
792 background: #ff4444;
793 border-color: #ff6666;
794 color: #fff;
795 transform: translateY(-50%) scale(1.1);
796 }
797
798 .shortcuts-container {
799 max-height: calc(100vh - 8rem);
800 overflow-y: auto;
801 }
802
803 .shortcuts-table {
804 width: 100%;
805 border-collapse: collapse;
806 background: #2d2d2d;
807 border-radius: 6px;
808 overflow: hidden;
809 }
810
811 .shortcut-row {
812 border-bottom: 1px solid #404040;
813 transition: background 0.2s;
814 }
815
816 .shortcut-row:last-child {
817 border-bottom: none;
818 }
819
820 .shortcut-row:hover {
821 background: #3d3d3d;
822 }
823
824 .shortcut-key {
825 padding: 0.4rem 0.8rem;
826 font-weight: bold;
827 color: #0066cc;
828 font-size: 0.85rem;
829 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
830 white-space: nowrap;
831 width: 1%;
832 }
833
834 .shortcut-desc {
835 padding: 0.4rem 0.8rem;
836 color: #e6e6e6;
837 font-size: 0.85rem;
838 }
839
840 .stats-title {
841 margin-top: 2rem;
842 }
843
844 .stats-container {
845 margin-top: 1rem;
846 }
847
848 .stats-grid {
849 display: flex;
850 gap: 1rem;
851 }
852
853 .stat-item {
854 background: #2d2d2d;
855 border-radius: 6px;
856 padding: 1rem;
857 text-align: center;
858 flex: 1;
859 border: 1px solid #404040;
860 }
861
862 .stat-value {
863 font-size: 1.5rem;
864 font-weight: bold;
865 color: #0066cc;
866 margin-bottom: 0.25rem;
867 }
868
869 .stat-label {
870 font-size: 0.8rem;
871 color: #888;
872 text-transform: uppercase;
873 letter-spacing: 0.5px;
874 }
875
876 .top-visited-title {
877 margin-top: 2rem;
878 }
879
880 .top-visited-container {
881 margin-top: 1rem;
882 }
883
884 .top-visited-list {
885 display: flex;
886 flex-direction: column;
887 gap: 0.5rem;
888 }
889
890 .top-visited-item {
891 display: flex;
892 align-items: center;
893 padding: 0.75rem;
894 background: #2d2d2d;
895 border-radius: 6px;
896 cursor: pointer;
897 transition: background 0.2s;
898 border: 1px solid #404040;
899 gap: 1rem;
900 }
901
902 .top-visited-item:hover {
903 background: #3d3d3d;
904 border-color: #0066cc;
905 }
906
907 .rank {
908 font-weight: bold;
909 color: #0066cc;
910 font-size: 1.1rem;
911 min-width: 1.5rem;
912 text-align: center;
913 }
914
915 .site-info {
916 flex: 1;
917 min-width: 0;
918 }
919
920 .site-title {
921 font-size: 0.9rem;
922 color: #e6e6e6;
923 margin-bottom: 0.2rem;
924 white-space: nowrap;
925 overflow: hidden;
926 text-overflow: ellipsis;
927 }
928
929 .site-domain {
930 font-size: 0.8rem;
931 color: #888;
932 }
933
934 .visit-count {
935 font-size: 0.85rem;
936 color: #0066cc;
937 font-weight: bold;
938 min-width: 2rem;
939 text-align: center;
940 }
941
942 .loading {
943 padding: 2rem;
944 text-align: center;
945 color: #888;
946 }
947
948 .scroll-sentinel {
949 height: 1px;
950 width: 100%;
951 }
952
953 .loading-more {
954 padding: 1rem;
955 text-align: center;
956 color: #888;
957 font-size: 0.9rem;
958 }
959
960 .empty-state {
961 padding: 3rem 2rem;
962 text-align: center;
963 color: #666;
964 }
965
966 .empty-state h3 {
967 margin-bottom: 1rem;
968 color: #888;
969 }
970
971 @media (max-width: 768px) {
972 .container {
973 flex-direction: column;
974 padding: 1rem;
975 }
976 }
977</style>