detached d84a0c8

feat: add pin/favorite sites and improve homepage UX

- Add pin functionality for websites from history and quick access
- Pin/unpin with star icons, localStorage persistence
- Pinned sites prioritized in "Jump back in" section
- Remove domain from history titles, highlight in URLs instead
- Replace ellipsis with gradient fade for long titles and URLs
- Remove 20-item limit, show all sites with 2+ visits
- Fix mobile URL overflow with proper width constraints
- Increase initial history display from 10 to 15 items
commit
d84a0c8783159c7b804ba01f74ae382e533c18f3
author
bamen · · 2025-09-25 04:22 UTC
parent
e9c8076
signature
unsigned
2 files · +446 -434
M CHANGELOG.md +9 -0
@@ -4,7 +4,16 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **Pin/favorite sites**: Pin websites from history or quick access to prioritize them in "Jump back in" section. Uses localStorage persistence with star icons for pin/unpin actions
9+
10+### Changed
11+- **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text
12+- **Quick access logic**: Removed 20-item limit, now shows all sites with 2+ visits (pinned sites bypass visit requirement)
13+- **History pagination**: Show 15 items initially (up from 10) with "Show N more" button
14+
715 ### Fixed
16+- **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes
817 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system
918
1019 ## [0.10.0] - 2025-09-24
@@ -4,7 +4,16 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Added
8+- **Pin/favorite sites**: Pin websites from history or quick access to prioritize them in "Jump back in" section. Uses localStorage persistence with star icons for pin/unpin actions
9+
10+### Changed
11+- **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text
12+- **Quick access logic**: Removed 20-item limit, now shows all sites with 2+ visits (pinned sites bypass visit requirement)
13+- **History pagination**: Show 15 items initially (up from 10) with "Show N more" button
14+
7 ### Fixed15 ### Fixed
16+- **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes
8 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system17 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system
9 18
10 ## [0.10.0] - 2025-09-2419 ## [0.10.0] - 2025-09-24
M gui/src/pages/Homepage.svelte +437 -434
@@ -38,54 +38,57 @@
3838 let historyOffset = $state(0);
3939 let hasMoreHistory = $state(true);
4040 let loadingMoreHistory = $state(false);
41- const MAX_QUICK_ACCESS_ITEMS = 9;
41+ const MIN_VISIT_COUNT_FOR_QUICK_ACCESS = 2;
4242 type ThemeMode = "light" | "dark";
4343 const THEME_STORAGE_KEY = "dumber.theme";
4444 let themeMode = $state<ThemeMode>("dark");
4545 let themeObserver: MutationObserver | null = null;
46- let historyHeaderRef = $state<HTMLElement | null>(null);
47- let historyBodyRef = $state<HTMLElement | null>(null);
48- let insightsPanelRef = $state<HTMLElement | null>(null);
49- let resizeSyncRaf = 0;
46+ const INITIAL_HISTORY_DISPLAY = 15;
47+ let showAllHistory = $state(false);
48+ const PINNED_SITES_STORAGE_KEY = "dumber.pinnedSites";
49+ let pinnedSites = $state<Set<string>>(new Set());
50+ let displayedHistoryItems = $derived(
51+ showAllHistory ? historyItems : historyItems.slice(0, INITIAL_HISTORY_DISPLAY)
52+ );
5053
51- const syncHistoryPanelSize = () => {
52- if (!historyBodyRef || !historyHeaderRef || !insightsPanelRef) return;
54+ // Function for checking if an item is being deleted
55+ const isDeleting = (id: number) => deletingIds.includes(id);
5356
54- if (window.innerWidth <= 960) {
55- historyBodyRef.style.maxHeight = "";
56- return;
57+ // Pinned sites management
58+ const loadPinnedSites = () => {
59+ try {
60+ const stored = localStorage.getItem(PINNED_SITES_STORAGE_KEY);
61+ if (stored) {
62+ const urls = JSON.parse(stored) as string[];
63+ pinnedSites = new Set(urls);
64+ }
65+ } catch (error) {
66+ console.warn("[homepage] Failed to load pinned sites", error);
67+ pinnedSites = new Set();
5768 }
69+ };
5870
59- const insightsHeight = insightsPanelRef.getBoundingClientRect().height;
60- if (!insightsHeight || Number.isNaN(insightsHeight)) {
61- historyBodyRef.style.maxHeight = "";
62- return;
71+ const savePinnedSites = () => {
72+ try {
73+ const urls = Array.from(pinnedSites);
74+ localStorage.setItem(PINNED_SITES_STORAGE_KEY, JSON.stringify(urls));
75+ } catch (error) {
76+ console.warn("[homepage] Failed to save pinned sites", error);
6377 }
78+ };
6479
65- const headerHeight = historyHeaderRef.getBoundingClientRect().height;
66- const bodyStyles = getComputedStyle(historyBodyRef);
67- const paddingTop = parseFloat(bodyStyles.paddingTop) || 0;
68- const paddingBottom = parseFloat(bodyStyles.paddingBottom) || 0;
69- const maxHeight = insightsHeight - headerHeight - paddingTop - paddingBottom;
80+ const isPinned = (url: string): boolean => pinnedSites.has(url);
7081
71- if (maxHeight > 0) {
72- historyBodyRef.style.maxHeight = `${maxHeight}px`;
82+ const togglePin = (url: string) => {
83+ if (pinnedSites.has(url)) {
84+ pinnedSites.delete(url);
7385 } else {
74- historyBodyRef.style.maxHeight = "";
86+ pinnedSites.add(url);
7587 }
88+ pinnedSites = new Set(pinnedSites); // Trigger reactivity
89+ savePinnedSites();
7690 };
7791
78- const scheduleHistoryPanelSync = () => {
79- if (resizeSyncRaf) cancelAnimationFrame(resizeSyncRaf);
80- resizeSyncRaf = requestAnimationFrame(() => {
81- resizeSyncRaf = 0;
82- syncHistoryPanelSize();
83- });
84- };
85-
86- // Function for checking if an item is being deleted
87- const isDeleting = (id: number) => deletingIds.includes(id);
88-
8992 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
9093 const initializeShortcuts = () => {
9194 shortcuts = [
@@ -396,6 +399,23 @@
396399 return domain || item.url;
397400 };
398401
402+ const highlightDomainInUrl = (url: string): string => {
403+ try {
404+ const urlObj = new URL(url);
405+ const domain = urlObj.hostname;
406+ const domainStart = url.indexOf(domain);
407+
408+ if (domainStart === -1) return url;
409+
410+ const beforeDomain = url.slice(0, domainStart);
411+ const afterDomain = url.slice(domainStart + domain.length);
412+
413+ return `${beforeDomain}<span class="history-url-domain">${domain}</span>${afterDomain}`;
414+ } catch {
415+ return url;
416+ }
417+ };
418+
399419 // Format relative time
400420 const formatTime = (timestamp: string): string => {
401421 try {
@@ -444,27 +464,42 @@
444464
445465 const pushItem = (item: HistoryItem | null | undefined) => {
446466 if (!item) return;
447- const domain = getDomain(item.url);
448- const key = domain || item.url;
449- if (seen.has(key)) return;
450- seen.add(key);
467+ if (seen.has(item.url)) return;
468+ seen.add(item.url);
451469 picks.push(item);
452470 };
453471
472+ // First, add pinned sites (no visit count requirement for pinned)
454473 for (const item of topVisited ?? []) {
455- pushItem(item);
456- if (picks.length >= MAX_QUICK_ACCESS_ITEMS) return picks;
474+ if (pinnedSites.has(item.url)) {
475+ pushItem(item);
476+ }
457477 }
458478
459479 for (const item of historyItems ?? []) {
460- pushItem(item);
461- if (picks.length >= MAX_QUICK_ACCESS_ITEMS) break;
480+ if (pinnedSites.has(item.url)) {
481+ pushItem(item);
482+ }
483+ }
484+
485+ // Then add most visited sites (non-pinned, with minimum visit count)
486+ for (const item of topVisited ?? []) {
487+ if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
488+ pushItem(item);
489+ }
490+ }
491+
492+ // Finally add recent history (non-pinned, with minimum visit count)
493+ for (const item of historyItems ?? []) {
494+ if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
495+ pushItem(item);
496+ }
462497 }
463498
464499 return picks;
465500 };
466501
467- let quickAccess = $derived<HistoryItem[]>(buildQuickAccess());
502+ let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));
468503 let quickAccessLoading = $derived(topVisitedLoading || historyLoading);
469504
470505 const persistTheme = (mode: ThemeMode) => {
@@ -598,6 +633,7 @@
598633 };
599634
600635 onMount(() => {
636+ loadPinnedSites();
601637 initializeShortcuts();
602638 fetchHistory().then(() => {
603639 // Setup infinite scroll after initial history is loaded
@@ -619,33 +655,12 @@
619655 attributeFilter: ["class"],
620656 });
621657
622- window.addEventListener('resize', scheduleHistoryPanelSync);
623-
624658 return () => {
625659 themeObserver?.disconnect();
626660 themeObserver = null;
627- window.removeEventListener('resize', scheduleHistoryPanelSync);
628- if (resizeSyncRaf) {
629- cancelAnimationFrame(resizeSyncRaf);
630- resizeSyncRaf = 0;
631- }
632661 };
633662 });
634663
635- $effect(() => {
636- historyItems;
637- topVisited;
638- stats;
639- shortcuts;
640- scheduleHistoryPanelSync();
641- });
642-
643- $effect(() => {
644- historyHeaderRef;
645- historyBodyRef;
646- insightsPanelRef;
647- scheduleHistoryPanelSync();
648- });
649664
650665 </script>
651666
@@ -691,86 +706,13 @@
691706 {/if}
692707 </button>
693708 </div>
694-
695- <section class="hero-panel brutal-panel">
696- <div class="hero-heading">
697- <h1 class="hero-title">Jump back in</h1>
698- <p class="hero-subtitle">Direct access to the sites you visit the most, plus what you just explored.</p>
699- </div>
700- <div class="quick-access-wrapper">
701- {#if quickAccessLoading}
702- <div class="loading quick-access-loading">Preparing your shortcuts...</div>
703- {:else if quickAccess.length === 0}
704- <div class="empty-state quick-access-empty">
705- <h3>No quick links yet</h3>
706- <p>Browse a few sites and we’ll surface your frequent destinations here.</p>
707- </div>
708- {:else}
709- <div class="quick-access-grid">
710- {#each quickAccess as item (item.id)}
711- <div
712- class="quick-access-item"
713- role="button"
714- tabindex="0"
715- onclick={() => navigateTo(item.url)}
716- onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
717- >
718- <div class="quick-access-favicon">
719- {#if item.favicon_url}
720- <img
721- src={item.favicon_url}
722- alt=""
723- class="quick-access-favicon-img"
724- onerror={(e) => {
725- const target = e.target as HTMLImageElement;
726- if (target) {
727- target.style.display = 'none';
728- const fallback = target.nextElementSibling as HTMLElement;
729- if (fallback) fallback.style.display = 'flex';
730- }
731- }}
732- onload={(e) => {
733- const target = e.target as HTMLImageElement;
734- if (target) {
735- const fallback = target.nextElementSibling as HTMLElement;
736- if (fallback) fallback.style.display = 'none';
737- }
738- }}
739- />
740- <div class="quick-access-fallback" style="display: none;">
741- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
742- <circle cx="12" cy="12" r="10" />
743- <path d="M2 12h20" />
744- </svg>
745- </div>
746- {:else}
747- <div class="quick-access-fallback">
748- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
749- <circle cx="12" cy="12" r="10" />
750- <path d="M2 12h20" />
751- </svg>
752- </div>
753- {/if}
754- </div>
755- <div class="quick-access-meta">
756- <div class="quick-access-title">{getDisplayTitle(item)}</div>
757- <div class="quick-access-domain">{getDomain(item.url)}</div>
758- </div>
759- <div class="quick-access-visits">{formatVisitLabel(item.visit_count)}</div>
760- </div>
761- {/each}
762- </div>
763- {/if}
764- </div>
765- </section>
766-
767709 <div class="main-panels">
768710 <section class="history-panel brutal-panel">
769- <div class="panel-header" bind:this={historyHeaderRef}>
711+ <div class="panel-header">
770712 <h2 class="panel-title">Recent History</h2>
771713 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
772714 </div>
773- <div class="panel-body history-body" bind:this={historyBodyRef}>
715+ <div class="panel-body history-body">
774716 {#if historyLoading}
775717 <div class="loading">Loading history...</div>
776718 {:else if historyItems.length === 0}
@@ -780,7 +722,7 @@
780722 </div>
781723 {:else}
782724 <div class="history-list">
783- {#each historyItems as item (item.id)}
725+ {#each displayedHistoryItems as item (item.id)}
784726 <div
785727 class="history-item"
786728 class:deleting={isDeleting(item.id)}
@@ -827,29 +769,54 @@
827769 {/if}
828770 </div>
829771 <div class="history-item-content">
830- <div class="history-item-top">
772+ <div class="history-item-main">
831773 <div class="history-title">{getDisplayTitle(item)}</div>
832774 <div class="history-time">{formatTime(item.last_visited)}</div>
833775 </div>
834- <div class="history-item-bottom">
835- <div class="history-domain">{getDomain(item.url)}</div>
836- <div class="history-url">{item.url}</div>
837- </div>
776+ <div class="history-url">{@html highlightDomainInUrl(item.url)}</div>
838777 </div>
839- <div
840- class="history-delete"
841- role="button"
842- tabindex="0"
843- onclick={(e) => deleteHistoryEntry(item.id, e)}
844- onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
845- title="Delete this entry"
846- >
847- <span aria-hidden="true">×</span>
778+ <div class="history-actions">
779+ <div
780+ class="history-pin"
781+ role="button"
782+ tabindex="0"
783+ onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
784+ onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
785+ title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
786+ >
787+ {#if isPinned(item.url)}
788+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
789+ <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"/>
790+ </svg>
791+ {:else}
792+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
793+ <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"/>
794+ </svg>
795+ {/if}
796+ </div>
797+ <div
798+ class="history-delete"
799+ role="button"
800+ tabindex="0"
801+ onclick={(e) => deleteHistoryEntry(item.id, e)}
802+ onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
803+ title="Delete this entry"
804+ >
805+ <span aria-hidden="true">×</span>
806+ </div>
848807 </div>
849808 </div>
850809 {/each}
851810
852- {#if hasMoreHistory}
811+ {#if !showAllHistory && historyItems.length > INITIAL_HISTORY_DISPLAY}
812+ <button
813+ class="show-more-button"
814+ onclick={() => showAllHistory = true}
815+ type="button"
816+ >
817+ Show {historyItems.length - INITIAL_HISTORY_DISPLAY} more items
818+ </button>
819+ {:else if showAllHistory && hasMoreHistory}
853820 <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
854821 {#if loadingMoreHistory}
855822 <div class="loading-more">Loading more history...</div>
@@ -860,75 +827,95 @@
860827 </div>
861828 </section>
862829
863- <section class="insights-panel brutal-panel" bind:this={insightsPanelRef}>
830+ <section class="quick-access-panel brutal-panel">
864831 <div class="panel-header">
865- <h2 class="panel-title">Usage Insights</h2>
866- <p class="panel-subtitle">Your browsing patterns at a glance.</p>
832+ <h2 class="panel-title">Jump back in</h2>
833+ <p class="panel-subtitle">Direct access to the sites you like the most.</p>
867834 </div>
868- <div class="panel-body insights-body">
869- <div class="insight-block">
870- <h3 class="block-title">Top sites</h3>
871- {#if topVisitedLoading}
872- <div class="loading">Loading usage insights...</div>
873- {:else if topVisited.length === 0}
874- <div class="empty-state compact">
875- <p>No visit data available</p>
876- </div>
877- {:else}
878- <div class="insights-list">
879- {#each topVisited as item, index (item.id)}
835+ <div class="panel-body quick-access-body">
836+ {#if quickAccessLoading}
837+ <div class="loading">Preparing your shortcuts...</div>
838+ {:else if quickAccess.length === 0}
839+ <div class="empty-state compact">
840+ <h3>No quick links yet</h3>
841+ <p>Browse a few sites and we'll surface your frequent destinations here.</p>
842+ </div>
843+ {:else}
844+ <div class="quick-access-grid-compact">
845+ {#each quickAccess as item (item.id)}
846+ <div
847+ class="quick-access-item-compact"
848+ role="button"
849+ tabindex="0"
850+ onclick={() => navigateTo(item.url)}
851+ onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
852+ >
853+ <div class="quick-access-favicon-compact">
854+ {#if item.favicon_url}
855+ <img
856+ src={item.favicon_url}
857+ alt=""
858+ class="quick-access-favicon-img-compact"
859+ onerror={(e) => {
860+ const target = e.target as HTMLImageElement;
861+ if (target) {
862+ target.style.display = 'none';
863+ const fallback = target.nextElementSibling as HTMLElement;
864+ if (fallback) fallback.style.display = 'flex';
865+ }
866+ }}
867+ onload={(e) => {
868+ const target = e.target as HTMLImageElement;
869+ if (target) {
870+ const fallback = target.nextElementSibling as HTMLElement;
871+ if (fallback) fallback.style.display = 'none';
872+ }
873+ }}
874+ />
875+ <div class="quick-access-fallback-compact" style="display: none;">
876+ <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">
877+ <circle cx="12" cy="12" r="10" />
878+ <path d="M2 12h20" />
879+ </svg>
880+ </div>
881+ {:else}
882+ <div class="quick-access-fallback-compact">
883+ <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">
884+ <circle cx="12" cy="12" r="10" />
885+ <path d="M2 12h20" />
886+ </svg>
887+ </div>
888+ {/if}
889+ </div>
890+ <div class="quick-access-meta-compact">
891+ <div class="quick-access-title-compact">{getDisplayTitle(item)}</div>
892+ <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
893+ </div>
894+ <div class="quick-access-actions-compact">
880895 <div
881- class="insight-item"
896+ class="quick-access-pin-compact"
882897 role="button"
883898 tabindex="0"
884- onclick={() => navigateTo(item.url)}
885- onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
899+ onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
900+ onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
901+ title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
886902 >
887- <div class="insight-rank">{index + 1}</div>
888- <div class="insight-body">
889- <div class="insight-title">{getDisplayTitle(item)}</div>
890- <div class="insight-domain">{getDomain(item.url)}</div>
891- </div>
892- <div class="insight-visits">{formatVisitLabel(item.visit_count)}</div>
903+ {#if isPinned(item.url)}
904+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
905+ <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"/>
906+ </svg>
907+ {:else}
908+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
909+ <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"/>
910+ </svg>
911+ {/if}
893912 </div>
894- {/each}
895- </div>
896- {/if}
897- </div>
898-
899- <div class="insight-block">
900- <h3 class="block-title">History snapshot</h3>
901- {#if statsLoading}
902- <div class="loading">Loading stats...</div>
903- {:else if stats}
904- <div class="stats-grid">
905- <div class="stat-item">
906- <div class="stat-value">{stats.total_entries}</div>
907- <div class="stat-label">Entries Stored</div>
913+ <div class="quick-access-visits-compact">{formatVisitLabel(item.visit_count)}</div>
908914 </div>
909- <div class="stat-item">
910- <div class="stat-value">{stats.recent_count}</div>
911- <div class="stat-label">Recent Window</div>
912- </div>
913- {#if stats.newest_entry}
914- <div class="stat-item">
915- <div class="stat-value">{formatTime(stats.newest_entry)}</div>
916- <div class="stat-label">Newest Visit</div>
917- </div>
918- {/if}
919- {#if stats.oldest_entry}
920- <div class="stat-item">
921- <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
922- <div class="stat-label">Oldest Entry Loaded</div>
923- </div>
924- {/if}
925- </div>
926- {:else}
927- <div class="empty-state compact">
928- <p>No statistics available</p>
929915 </div>
930- {/if}
916+ {/each}
931917 </div>
918+ {/if}
932919 </div>
933920 </section>
934921 </div>
@@ -953,6 +940,47 @@
953940 {/if}
954941 </div>
955942 </section>
943+
944+
945+ <section class="stats-panel brutal-panel">
946+ <div class="panel-header">
947+ <h2 class="panel-title">Usage Stats</h2>
948+ <p class="panel-subtitle">Your browsing activity at a glance.</p>
949+ </div>
950+ <div class="panel-body">
951+ {#if statsLoading}
952+ <div class="loading">Loading stats...</div>
953+ {:else if stats}
954+ <div class="stats-grid-horizontal">
955+ <div class="stat-item-horizontal">
956+ <div class="stat-value">{stats.total_entries}</div>
957+ <div class="stat-label">Entries Stored</div>
958+ </div>
959+ <div class="stat-item-horizontal">
960+ <div class="stat-value">{stats.recent_count}</div>
961+ <div class="stat-label">Recent Window</div>
962+ </div>
963+ {#if stats.newest_entry}
964+ <div class="stat-item-horizontal">
965+ <div class="stat-value">{formatTime(stats.newest_entry)}</div>
966+ <div class="stat-label">Newest Visit</div>
967+ </div>
968+ {/if}
969+ {#if stats.oldest_entry}
970+ <div class="stat-item-horizontal">
971+ <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
972+ <div class="stat-label">Oldest Entry</div>
973+ </div>
974+ {/if}
975+ </div>
976+ {:else}
977+ <div class="empty-state compact">
978+ <p>No statistics available</p>
979+ </div>
980+ {/if}
981+ </div>
982+ </section>
983+
956984 </div>
957985
958986 <Footer />
@@ -1039,126 +1067,7 @@
10391067 gap: 0;
10401068 }
10411069
1042-.hero-panel {
1043- gap: clamp(1.25rem, 2vw, 1.75rem);
1044- padding: clamp(1.75rem, 2.2vw, 2.5rem);
1045-}
1046-
1047-.hero-heading {
1048- display: flex;
1049- flex-direction: column;
1050- gap: 0.5rem;
1051- max-width: 36rem;
1052-}
1053-
1054-.hero-title {
1055- font-size: clamp(2rem, 3vw, 2.75rem);
1056- font-weight: 700;
1057- letter-spacing: -0.01em;
1058- margin: 0;
1059-}
1060-
1061-.hero-subtitle {
1062- font-size: 1rem;
1063- line-height: 1.6;
1064- color: var(--dynamic-muted);
1065- margin: 0;
1066-}
1067-
1068-.quick-access-wrapper {
1069- display: flex;
1070- flex-direction: column;
1071- gap: 1rem;
1072-}
1073-
1074-.quick-access-grid {
1075- display: flex;
1076- flex-wrap: wrap;
1077- gap: 1rem;
1078-}
1079-
1080-.quick-access-item {
1081- flex: 1 1 min(16rem, 100%);
1082- min-width: 12rem;
1083- display: flex;
1084- align-items: center;
1085- gap: 1rem;
1086- border: 2px solid var(--dynamic-border);
1087- background-color: var(--dynamic-surface-variant);
1088- padding: 0.9rem 1.1rem;
1089- cursor: pointer;
1090- border-radius: 0;
1091- transition:
1092- border-color 200ms ease,
1093- background-color 200ms ease;
1094-}
1095-
1096-.quick-access-item:hover,
1097-.quick-access-item:focus-visible {
1098- border-color: var(--dynamic-accent);
1099- outline: none;
1100-}
1101-
1102-.quick-access-favicon {
1103- width: 48px;
1104- height: 48px;
1105- border: 2px solid var(--dynamic-border);
1106- background-color: var(--dynamic-surface);
1107- display: flex;
1108- align-items: center;
1109- justify-content: center;
1110- flex-shrink: 0;
1111- border-radius: 0;
1112-}
1113-
1114-.quick-access-favicon-img {
1115- width: 70%;
1116- height: 70%;
1117- object-fit: contain;
1118-}
1119-
1120-.quick-access-fallback {
1121- width: 100%;
1122- height: 100%;
1123- display: flex;
1124- align-items: center;
1125- justify-content: center;
1126- color: var(--dynamic-muted);
1127-}
1128-
1129-.quick-access-meta {
1130- flex: 1;
1131- min-width: 0;
1132- display: flex;
1133- flex-direction: column;
1134- gap: 0.125rem;
1135-}
1136-
1137-.quick-access-title {
1138- font-size: 1rem;
1139- font-weight: 600;
1140- color: var(--dynamic-text);
1141- white-space: nowrap;
1142- overflow: hidden;
1143- text-overflow: ellipsis;
1144-}
11451070
1146-.quick-access-domain {
1147- font-size: 0.85rem;
1148- color: var(--dynamic-muted);
1149- white-space: nowrap;
1150- overflow: hidden;
1151- text-overflow: ellipsis;
1152-}
1153-
1154-.quick-access-visits {
1155- font-size: 0.82rem;
1156- color: var(--dynamic-accent);
1157- text-transform: uppercase;
1158- letter-spacing: 0.05em;
1159- font-weight: 600;
1160- flex-shrink: 0;
1161-}
11621071
11631072 .loading {
11641073 padding: 1.25rem;
@@ -1211,7 +1120,7 @@
12111120 min-height: 0;
12121121 }
12131122
1214-.insights-panel {
1123+.quick-access-panel {
12151124 flex: 1 1 35%;
12161125 min-width: min(24rem, 100%);
12171126 display: flex;
@@ -1264,28 +1173,31 @@
12641173 flex: 1;
12651174 min-height: 0;
12661175 overflow-y: auto;
1267- padding-right: 0.5rem;
1176+ background-color: var(--dynamic-surface-variant);
1177+ border: 2px solid var(--dynamic-border);
1178+ padding: 0.5rem;
12681179 }
12691180
12701181 .history-item {
12711182 display: flex;
12721183 align-items: flex-start;
1273- gap: 1rem;
1274- border: 2px solid var(--dynamic-border);
1184+ gap: 0.75rem;
1185+ border-bottom: 1px solid var(--dynamic-border);
12751186 background-color: var(--dynamic-surface-variant);
1276- padding: 1rem 1.1rem;
1187+ padding: 0.75rem 0.5rem;
12771188 cursor: pointer;
1278- min-height: 80px;
1279- border-radius: 0;
12801189 transition:
1281- border-color 200ms ease,
1282- background-color 200ms ease;
1190+ background-color 200ms ease,
1191+ border-color 200ms ease;
1192+}
1193+
1194+.history-item:last-child {
1195+ border-bottom: none;
12831196 }
12841197
12851198 .history-item:hover,
12861199 .history-item:focus-visible {
1287- border-color: var(--dynamic-accent);
1288- background-color: var(--dynamic-bg);
1200+ background-color: var(--dynamic-surface);
12891201 outline: none;
12901202 }
12911203
@@ -1327,11 +1239,12 @@
13271239 min-width: 0;
13281240 display: flex;
13291241 flex-direction: column;
1330- gap: 0.125rem;
1242+ gap: 0.25rem;
13311243 justify-content: center;
1244+ overflow: hidden;
13321245 }
13331246
1334-.history-item-top {
1247+.history-item-main {
13351248 display: flex;
13361249 align-items: flex-start;
13371250 justify-content: space-between;
@@ -1343,31 +1256,19 @@
13431256 color: var(--dynamic-text);
13441257 white-space: nowrap;
13451258 overflow: hidden;
1346- text-overflow: ellipsis;
1259+ flex: 1;
1260+ position: relative;
1261+ mask-image: linear-gradient(to right, black 70%, transparent 100%);
1262+ -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
13471263 }
13481264
1265+
13491266 .history-time {
13501267 font-size: 0.85rem;
13511268 color: var(--dynamic-muted);
13521269 flex-shrink: 0;
13531270 }
13541271
1355-.history-item-bottom {
1356- display: flex;
1357- align-items: baseline;
1358- gap: 0.5rem;
1359- overflow: hidden;
1360- position: relative;
1361-}
1362-
1363-.history-domain {
1364- font-size: 0.9rem;
1365- font-weight: 500;
1366- color: var(--dynamic-text);
1367- white-space: nowrap;
1368- flex-shrink: 0;
1369-}
1370-
13711272 .history-url {
13721273 font-size: 0.8rem;
13731274 color: var(--dynamic-muted);
@@ -1379,11 +1280,41 @@
13791280 monospace;
13801281 overflow: hidden;
13811282 flex: 1;
1283+ min-width: 0;
13821284 position: relative;
13831285 mask-image: linear-gradient(to right, black 70%, transparent 100%);
13841286 -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
13851287 }
13861288
1289+.history-url :global(.history-url-domain) {
1290+ color: var(--dynamic-text) !important;
1291+ font-weight: 600;
1292+}
1293+
1294+.history-actions {
1295+ display: flex;
1296+ flex-shrink: 0;
1297+}
1298+
1299+.history-pin {
1300+ display: flex;
1301+ align-items: center;
1302+ justify-content: center;
1303+ width: 38px;
1304+ border-left: 2px solid var(--dynamic-border);
1305+ background-color: var(--dynamic-surface);
1306+ color: var(--dynamic-muted);
1307+ transition: background-color 200ms ease, color 200ms ease;
1308+ cursor: pointer;
1309+}
1310+
1311+.history-pin:hover,
1312+.history-pin:focus-visible {
1313+ color: var(--dynamic-accent);
1314+ background-color: var(--dynamic-bg);
1315+ outline: none;
1316+}
1317+
13871318 .history-delete {
13881319 display: flex;
13891320 align-items: center;
@@ -1416,66 +1347,68 @@
14161347 font-size: 0.85rem;
14171348 }
14181349
1419-.insights-body {
1420- gap: 1.5rem;
1421-}
1422-
1423-.insight-block {
1424- display: flex;
1425- flex-direction: column;
1426- gap: 0.9rem;
1427-}
1428-
1429-.block-title {
1430- font-size: 0.85rem;
1431- text-transform: uppercase;
1432- letter-spacing: 0.08em;
1433- color: var(--dynamic-muted);
1434- margin: 0;
1350+.quick-access-body {
1351+ gap: 1rem;
14351352 }
14361353
1437-.insights-list {
1354+.quick-access-grid-compact {
14381355 display: flex;
14391356 flex-direction: column;
1440- gap: 0.75rem;
1357+ gap: 0.5rem;
1358+ background-color: var(--dynamic-surface-variant);
1359+ border: 2px solid var(--dynamic-border);
1360+ padding: 0.5rem;
14411361 }
14421362
1443-.insight-item {
1363+.quick-access-item-compact {
14441364 display: flex;
14451365 align-items: center;
14461366 gap: 0.75rem;
1447- border: 2px solid var(--dynamic-border);
1367+ border-bottom: 1px solid var(--dynamic-border);
14481368 background-color: var(--dynamic-surface-variant);
1449- padding: 0.85rem 1rem;
1369+ padding: 0.5rem 0.25rem;
14501370 cursor: pointer;
1451- border-radius: 0;
1452- transition:
1453- border-color 200ms ease,
1454- background-color 200ms ease;
1371+ transition: background-color 200ms ease;
14551372 }
14561373
1457-.insight-item:hover,
1458-.insight-item:focus-visible {
1459- border-color: var(--dynamic-accent);
1374+.quick-access-item-compact:last-child {
1375+ border-bottom: none;
1376+}
1377+
1378+.quick-access-item-compact:hover,
1379+.quick-access-item-compact:focus-visible {
1380+ background-color: var(--dynamic-surface);
14601381 outline: none;
14611382 }
14621383
1463-.insight-rank {
1464- width: 2rem;
1465- height: 2rem;
1384+.quick-access-favicon-compact {
1385+ width: 32px;
1386+ height: 32px;
14661387 border: 2px solid var(--dynamic-border);
1467- background-color: var(--dynamic-surface);
1388+ background-color: var(--dynamic-surface-variant);
14681389 display: flex;
14691390 align-items: center;
14701391 justify-content: center;
1471- font-weight: 600;
1472- font-size: 0.9rem;
1473- color: var(--dynamic-accent);
14741392 flex-shrink: 0;
14751393 border-radius: 0;
14761394 }
14771395
1478-.insight-body {
1396+.quick-access-favicon-img-compact {
1397+ width: 70%;
1398+ height: 70%;
1399+ object-fit: contain;
1400+}
1401+
1402+.quick-access-fallback-compact {
1403+ width: 100%;
1404+ height: 100%;
1405+ display: flex;
1406+ align-items: center;
1407+ justify-content: center;
1408+ color: var(--dynamic-muted);
1409+}
1410+
1411+.quick-access-meta-compact {
14791412 flex: 1;
14801413 min-width: 0;
14811414 display: flex;
@@ -1483,48 +1416,63 @@
14831416 gap: 0.125rem;
14841417 }
14851418
1486-.insight-title {
1419+.quick-access-title-compact {
1420+ font-size: 0.95rem;
14871421 font-weight: 600;
14881422 color: var(--dynamic-text);
14891423 white-space: nowrap;
14901424 overflow: hidden;
1491- text-overflow: ellipsis;
1425+ position: relative;
1426+ mask-image: linear-gradient(to right, black 70%, transparent 100%);
1427+ -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
14921428 }
14931429
1494-.insight-domain {
1495- font-size: 0.85rem;
1430+.quick-access-domain-compact {
1431+ font-size: 0.8rem;
14961432 color: var(--dynamic-muted);
14971433 white-space: nowrap;
14981434 overflow: hidden;
14991435 text-overflow: ellipsis;
15001436 }
15011437
1502-.insight-visits {
1503- font-size: 0.78rem;
1504- color: var(--dynamic-accent);
1505- text-transform: uppercase;
1506- letter-spacing: 0.05em;
1507- font-weight: 600;
1438+.quick-access-actions-compact {
1439+ display: flex;
1440+ align-items: center;
1441+ gap: 0.5rem;
15081442 flex-shrink: 0;
15091443 }
15101444
1511-.stats-grid {
1445+.quick-access-pin-compact {
15121446 display: flex;
1513- flex-wrap: wrap;
1514- gap: 0.85rem;
1515-}
1516-
1517-.stat-item {
1518- flex: 1 1 160px;
1519- border: 2px solid var(--dynamic-border);
1447+ align-items: center;
1448+ justify-content: center;
1449+ width: 24px;
1450+ height: 24px;
1451+ border: 1px solid var(--dynamic-border);
15201452 background-color: var(--dynamic-surface-variant);
1521- padding: 1rem;
1522- display: flex;
1523- flex-direction: column;
1524- gap: 0.35rem;
1453+ color: var(--dynamic-muted);
1454+ transition: background-color 200ms ease, color 200ms ease;
1455+ cursor: pointer;
15251456 border-radius: 0;
15261457 }
15271458
1459+.quick-access-pin-compact:hover,
1460+.quick-access-pin-compact:focus-visible {
1461+ color: var(--dynamic-accent);
1462+ background-color: var(--dynamic-surface);
1463+ outline: none;
1464+}
1465+
1466+.quick-access-visits-compact {
1467+ font-size: 0.75rem;
1468+ color: var(--dynamic-accent);
1469+ text-transform: uppercase;
1470+ letter-spacing: 0.05em;
1471+ font-weight: 600;
1472+ flex-shrink: 0;
1473+}
1474+
1475+
15281476 .stat-value {
15291477 font-size: 1.4rem;
15301478 font-weight: 600;
@@ -1538,6 +1486,26 @@
15381486 letter-spacing: 0.08em;
15391487 }
15401488
1489+.stats-grid-horizontal {
1490+ display: flex;
1491+ flex-wrap: wrap;
1492+ gap: 1rem;
1493+ justify-content: space-between;
1494+}
1495+
1496+.stat-item-horizontal {
1497+ flex: 1;
1498+ min-width: 120px;
1499+ border: 2px solid var(--dynamic-border);
1500+ background-color: var(--dynamic-surface-variant);
1501+ padding: 0.75rem;
1502+ display: flex;
1503+ flex-direction: column;
1504+ gap: 0.25rem;
1505+ text-align: center;
1506+ border-radius: 0;
1507+}
1508+
15411509 .shortcuts-panel {
15421510 gap: 0;
15431511 }
@@ -1587,7 +1555,7 @@
15871555 /* Responsive Design */
15881556 @media (max-width: 1200px) {
15891557 .history-panel,
1590- .insights-panel {
1558+ .quick-access-panel {
15911559 min-width: min(28rem, 100%);
15921560 }
15931561 }
@@ -1602,13 +1570,12 @@
16021570 }
16031571
16041572 .history-panel,
1605- .insights-panel {
1573+ .quick-access-panel {
16061574 min-width: 100%;
1575+ max-width: 100%;
1576+ width: 100%;
16071577 }
16081578
1609- .quick-access-item {
1610- flex: 1 1 calc(50% - 1rem);
1611- }
16121579 }
16131580
16141581 @media (max-width: 640px) {
@@ -1618,29 +1585,33 @@
16181585 }
16191586
16201587 .panel-body,
1621- .panel-header,
1622- .hero-panel {
1588+ .panel-header {
16231589 padding-left: 1.25rem;
16241590 padding-right: 1.25rem;
16251591 }
16261592
1627- .quick-access-item {
1628- flex: 1 1 100%;
1629- }
16301593
16311594 .history-item {
16321595 flex-direction: column;
16331596 align-items: flex-start;
16341597 }
16351598
1636- .history-delete {
1599+ .history-actions {
16371600 width: 100%;
1638- border-left: 0;
16391601 border-top: 2px solid var(--dynamic-border);
1640- align-self: stretch;
16411602 }
16421603
1643- .history-item-top {
1604+ .history-pin,
1605+ .history-delete {
1606+ flex: 1;
1607+ border-left: 0;
1608+ }
1609+
1610+ .history-pin {
1611+ border-right: 1px solid var(--dynamic-border);
1612+ }
1613+
1614+ .history-item-main {
16441615 flex-direction: column;
16451616 align-items: flex-start;
16461617 gap: 0.35rem;
@@ -1649,5 +1620,37 @@
16491620 .history-time {
16501621 order: 3;
16511622 }
1623+
1624+ .stats-grid-horizontal {
1625+ flex-direction: column;
1626+ gap: 0.75rem;
1627+ }
1628+
1629+ .stat-item-horizontal {
1630+ min-width: 100%;
1631+ }
1632+}
1633+
1634+.show-more-button {
1635+ width: 100%;
1636+ padding: 0.75rem 1rem;
1637+ border: 2px solid var(--dynamic-border);
1638+ background-color: var(--dynamic-surface-variant);
1639+ color: var(--dynamic-text);
1640+ font-size: 0.9rem;
1641+ font-weight: 500;
1642+ cursor: pointer;
1643+ transition:
1644+ background-color 200ms ease,
1645+ border-color 200ms ease;
1646+ border-radius: 0;
1647+ margin-top: 0.5rem;
1648+}
1649+
1650+.show-more-button:hover,
1651+.show-more-button:focus-visible {
1652+ background-color: var(--dynamic-surface);
1653+ border-color: var(--dynamic-accent);
1654+ outline: none;
16521655 }
16531656 </style>
@@ -38,54 +38,57 @@
38 let historyOffset = $state(0);38 let historyOffset = $state(0);
39 let hasMoreHistory = $state(true);39 let hasMoreHistory = $state(true);
40 let loadingMoreHistory = $state(false);40 let loadingMoreHistory = $state(false);
41- const MAX_QUICK_ACCESS_ITEMS = 9;41+ const MIN_VISIT_COUNT_FOR_QUICK_ACCESS = 2;
42 type ThemeMode = "light" | "dark";42 type ThemeMode = "light" | "dark";
43 const THEME_STORAGE_KEY = "dumber.theme";43 const THEME_STORAGE_KEY = "dumber.theme";
44 let themeMode = $state<ThemeMode>("dark");44 let themeMode = $state<ThemeMode>("dark");
45 let themeObserver: MutationObserver | null = null;45 let themeObserver: MutationObserver | null = null;
46- let historyHeaderRef = $state<HTMLElement | null>(null);46+ const INITIAL_HISTORY_DISPLAY = 15;
47- let historyBodyRef = $state<HTMLElement | null>(null);47+ let showAllHistory = $state(false);
48- let insightsPanelRef = $state<HTMLElement | null>(null);48+ const PINNED_SITES_STORAGE_KEY = "dumber.pinnedSites";
49- let resizeSyncRaf = 0;49+ let pinnedSites = $state<Set<string>>(new Set());
50+ let displayedHistoryItems = $derived(
51+ showAllHistory ? historyItems : historyItems.slice(0, INITIAL_HISTORY_DISPLAY)
52+ );
50 53
51- const syncHistoryPanelSize = () => {54+ // Function for checking if an item is being deleted
52- if (!historyBodyRef || !historyHeaderRef || !insightsPanelRef) return;55+ const isDeleting = (id: number) => deletingIds.includes(id);
53 56
54- if (window.innerWidth <= 960) {57+ // Pinned sites management
55- historyBodyRef.style.maxHeight = "";58+ const loadPinnedSites = () => {
56- return;59+ try {
60+ const stored = localStorage.getItem(PINNED_SITES_STORAGE_KEY);
61+ if (stored) {
62+ const urls = JSON.parse(stored) as string[];
63+ pinnedSites = new Set(urls);
64+ }
65+ } catch (error) {
66+ console.warn("[homepage] Failed to load pinned sites", error);
67+ pinnedSites = new Set();
57 }68 }
69+ };
58 70
59- const insightsHeight = insightsPanelRef.getBoundingClientRect().height;71+ const savePinnedSites = () => {
60- if (!insightsHeight || Number.isNaN(insightsHeight)) {72+ try {
61- historyBodyRef.style.maxHeight = "";73+ const urls = Array.from(pinnedSites);
62- return;74+ localStorage.setItem(PINNED_SITES_STORAGE_KEY, JSON.stringify(urls));
75+ } catch (error) {
76+ console.warn("[homepage] Failed to save pinned sites", error);
63 }77 }
78+ };
64 79
65- const headerHeight = historyHeaderRef.getBoundingClientRect().height;80+ const isPinned = (url: string): boolean => pinnedSites.has(url);
66- const bodyStyles = getComputedStyle(historyBodyRef);
67- const paddingTop = parseFloat(bodyStyles.paddingTop) || 0;
68- const paddingBottom = parseFloat(bodyStyles.paddingBottom) || 0;
69- const maxHeight = insightsHeight - headerHeight - paddingTop - paddingBottom;
70 81
71- if (maxHeight > 0) {82+ const togglePin = (url: string) => {
72- historyBodyRef.style.maxHeight = `${maxHeight}px`;83+ if (pinnedSites.has(url)) {
84+ pinnedSites.delete(url);
73 } else {85 } else {
74- historyBodyRef.style.maxHeight = "";86+ pinnedSites.add(url);
75 }87 }
88+ pinnedSites = new Set(pinnedSites); // Trigger reactivity
89+ savePinnedSites();
76 };90 };
77 91
78- const scheduleHistoryPanelSync = () => {
79- if (resizeSyncRaf) cancelAnimationFrame(resizeSyncRaf);
80- resizeSyncRaf = requestAnimationFrame(() => {
81- resizeSyncRaf = 0;
82- syncHistoryPanelSize();
83- });
84- };
85-
86- // Function for checking if an item is being deleted
87- const isDeleting = (id: number) => deletingIds.includes(id);
88-
89 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)92 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
90 const initializeShortcuts = () => {93 const initializeShortcuts = () => {
91 shortcuts = [94 shortcuts = [
@@ -396,6 +399,23 @@
396 return domain || item.url;399 return domain || item.url;
397 };400 };
398 401
402+ const highlightDomainInUrl = (url: string): string => {
403+ try {
404+ const urlObj = new URL(url);
405+ const domain = urlObj.hostname;
406+ const domainStart = url.indexOf(domain);
407+
408+ if (domainStart === -1) return url;
409+
410+ const beforeDomain = url.slice(0, domainStart);
411+ const afterDomain = url.slice(domainStart + domain.length);
412+
413+ return `${beforeDomain}<span class="history-url-domain">${domain}</span>${afterDomain}`;
414+ } catch {
415+ return url;
416+ }
417+ };
418+
399 // Format relative time419 // Format relative time
400 const formatTime = (timestamp: string): string => {420 const formatTime = (timestamp: string): string => {
401 try {421 try {
@@ -444,27 +464,42 @@
444 464
445 const pushItem = (item: HistoryItem | null | undefined) => {465 const pushItem = (item: HistoryItem | null | undefined) => {
446 if (!item) return;466 if (!item) return;
447- const domain = getDomain(item.url);467+ if (seen.has(item.url)) return;
448- const key = domain || item.url;468+ seen.add(item.url);
449- if (seen.has(key)) return;
450- seen.add(key);
451 picks.push(item);469 picks.push(item);
452 };470 };
453 471
472+ // First, add pinned sites (no visit count requirement for pinned)
454 for (const item of topVisited ?? []) {473 for (const item of topVisited ?? []) {
455- pushItem(item);474+ if (pinnedSites.has(item.url)) {
456- if (picks.length >= MAX_QUICK_ACCESS_ITEMS) return picks;475+ pushItem(item);
476+ }
457 }477 }
458 478
459 for (const item of historyItems ?? []) {479 for (const item of historyItems ?? []) {
460- pushItem(item);480+ if (pinnedSites.has(item.url)) {
461- if (picks.length >= MAX_QUICK_ACCESS_ITEMS) break;481+ pushItem(item);
482+ }
483+ }
484+
485+ // Then add most visited sites (non-pinned, with minimum visit count)
486+ for (const item of topVisited ?? []) {
487+ if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
488+ pushItem(item);
489+ }
490+ }
491+
492+ // Finally add recent history (non-pinned, with minimum visit count)
493+ for (const item of historyItems ?? []) {
494+ if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
495+ pushItem(item);
496+ }
462 }497 }
463 498
464 return picks;499 return picks;
465 };500 };
466 501
467- let quickAccess = $derived<HistoryItem[]>(buildQuickAccess());502+ let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));
468 let quickAccessLoading = $derived(topVisitedLoading || historyLoading);503 let quickAccessLoading = $derived(topVisitedLoading || historyLoading);
469 504
470 const persistTheme = (mode: ThemeMode) => {505 const persistTheme = (mode: ThemeMode) => {
@@ -598,6 +633,7 @@
598 };633 };
599 634
600 onMount(() => {635 onMount(() => {
636+ loadPinnedSites();
601 initializeShortcuts();637 initializeShortcuts();
602 fetchHistory().then(() => {638 fetchHistory().then(() => {
603 // Setup infinite scroll after initial history is loaded639 // Setup infinite scroll after initial history is loaded
@@ -619,33 +655,12 @@
619 attributeFilter: ["class"],655 attributeFilter: ["class"],
620 });656 });
621 657
622- window.addEventListener('resize', scheduleHistoryPanelSync);
623-
624 return () => {658 return () => {
625 themeObserver?.disconnect();659 themeObserver?.disconnect();
626 themeObserver = null;660 themeObserver = null;
627- window.removeEventListener('resize', scheduleHistoryPanelSync);
628- if (resizeSyncRaf) {
629- cancelAnimationFrame(resizeSyncRaf);
630- resizeSyncRaf = 0;
631- }
632 };661 };
633 });662 });
634 663
635- $effect(() => {
636- historyItems;
637- topVisited;
638- stats;
639- shortcuts;
640- scheduleHistoryPanelSync();
641- });
642-
643- $effect(() => {
644- historyHeaderRef;
645- historyBodyRef;
646- insightsPanelRef;
647- scheduleHistoryPanelSync();
648- });
649 664
650 </script>665 </script>
651 666
@@ -691,86 +706,13 @@
691 {/if}706 {/if}
692 </button>707 </button>
693 </div>708 </div>
694-
695- <section class="hero-panel brutal-panel">
696- <div class="hero-heading">
697- <h1 class="hero-title">Jump back in</h1>
698- <p class="hero-subtitle">Direct access to the sites you visit the most, plus what you just explored.</p>
699- </div>
700- <div class="quick-access-wrapper">
701- {#if quickAccessLoading}
702- <div class="loading quick-access-loading">Preparing your shortcuts...</div>
703- {:else if quickAccess.length === 0}
704- <div class="empty-state quick-access-empty">
705- <h3>No quick links yet</h3>
706- <p>Browse a few sites and we’ll surface your frequent destinations here.</p>
707- </div>
708- {:else}
709- <div class="quick-access-grid">
710- {#each quickAccess as item (item.id)}
711- <div
712- class="quick-access-item"
713- role="button"
714- tabindex="0"
715- onclick={() => navigateTo(item.url)}
716- onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
717- >
718- <div class="quick-access-favicon">
719- {#if item.favicon_url}
720- <img
721- src={item.favicon_url}
722- alt=""
723- class="quick-access-favicon-img"
724- onerror={(e) => {
725- const target = e.target as HTMLImageElement;
726- if (target) {
727- target.style.display = 'none';
728- const fallback = target.nextElementSibling as HTMLElement;
729- if (fallback) fallback.style.display = 'flex';
730- }
731- }}
732- onload={(e) => {
733- const target = e.target as HTMLImageElement;
734- if (target) {
735- const fallback = target.nextElementSibling as HTMLElement;
736- if (fallback) fallback.style.display = 'none';
737- }
738- }}
739- />
740- <div class="quick-access-fallback" style="display: none;">
741- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
742- <circle cx="12" cy="12" r="10" />
743- <path d="M2 12h20" />
744- </svg>
745- </div>
746- {:else}
747- <div class="quick-access-fallback">
748- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
749- <circle cx="12" cy="12" r="10" />
750- <path d="M2 12h20" />
751- </svg>
752- </div>
753- {/if}
754- </div>
755- <div class="quick-access-meta">
756- <div class="quick-access-title">{getDisplayTitle(item)}</div>
757- <div class="quick-access-domain">{getDomain(item.url)}</div>
758- </div>
759- <div class="quick-access-visits">{formatVisitLabel(item.visit_count)}</div>
760- </div>
761- {/each}
762- </div>
763- {/if}
764- </div>
765- </section>
766-
767 <div class="main-panels">709 <div class="main-panels">
768 <section class="history-panel brutal-panel">710 <section class="history-panel brutal-panel">
769- <div class="panel-header" bind:this={historyHeaderRef}>711+ <div class="panel-header">
770 <h2 class="panel-title">Recent History</h2>712 <h2 class="panel-title">Recent History</h2>
771 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>713 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
772 </div>714 </div>
773- <div class="panel-body history-body" bind:this={historyBodyRef}>715+ <div class="panel-body history-body">
774 {#if historyLoading}716 {#if historyLoading}
775 <div class="loading">Loading history...</div>717 <div class="loading">Loading history...</div>
776 {:else if historyItems.length === 0}718 {:else if historyItems.length === 0}
@@ -780,7 +722,7 @@
780 </div>722 </div>
781 {:else}723 {:else}
782 <div class="history-list">724 <div class="history-list">
783- {#each historyItems as item (item.id)}725+ {#each displayedHistoryItems as item (item.id)}
784 <div726 <div
785 class="history-item"727 class="history-item"
786 class:deleting={isDeleting(item.id)}728 class:deleting={isDeleting(item.id)}
@@ -827,29 +769,54 @@
827 {/if}769 {/if}
828 </div>770 </div>
829 <div class="history-item-content">771 <div class="history-item-content">
830- <div class="history-item-top">772+ <div class="history-item-main">
831 <div class="history-title">{getDisplayTitle(item)}</div>773 <div class="history-title">{getDisplayTitle(item)}</div>
832 <div class="history-time">{formatTime(item.last_visited)}</div>774 <div class="history-time">{formatTime(item.last_visited)}</div>
833 </div>775 </div>
834- <div class="history-item-bottom">776+ <div class="history-url">{@html highlightDomainInUrl(item.url)}</div>
835- <div class="history-domain">{getDomain(item.url)}</div>
836- <div class="history-url">{item.url}</div>
837- </div>
838 </div>777 </div>
839- <div778+ <div class="history-actions">
840- class="history-delete"779+ <div
841- role="button"780+ class="history-pin"
842- tabindex="0"781+ role="button"
843- onclick={(e) => deleteHistoryEntry(item.id, e)}782+ tabindex="0"
844- onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}783+ onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
845- title="Delete this entry"784+ onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
846- >785+ title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
847- <span aria-hidden="true">×</span>786+ >
787+ {#if isPinned(item.url)}
788+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
789+ <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"/>
790+ </svg>
791+ {:else}
792+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
793+ <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"/>
794+ </svg>
795+ {/if}
796+ </div>
797+ <div
798+ class="history-delete"
799+ role="button"
800+ tabindex="0"
801+ onclick={(e) => deleteHistoryEntry(item.id, e)}
802+ onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
803+ title="Delete this entry"
804+ >
805+ <span aria-hidden="true">×</span>
806+ </div>
848 </div>807 </div>
849 </div>808 </div>
850 {/each}809 {/each}
851 810
852- {#if hasMoreHistory}811+ {#if !showAllHistory && historyItems.length > INITIAL_HISTORY_DISPLAY}
812+ <button
813+ class="show-more-button"
814+ onclick={() => showAllHistory = true}
815+ type="button"
816+ >
817+ Show {historyItems.length - INITIAL_HISTORY_DISPLAY} more items
818+ </button>
819+ {:else if showAllHistory && hasMoreHistory}
853 <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>820 <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
854 {#if loadingMoreHistory}821 {#if loadingMoreHistory}
855 <div class="loading-more">Loading more history...</div>822 <div class="loading-more">Loading more history...</div>
@@ -860,75 +827,95 @@
860 </div>827 </div>
861 </section>828 </section>
862 829
863- <section class="insights-panel brutal-panel" bind:this={insightsPanelRef}>830+ <section class="quick-access-panel brutal-panel">
864 <div class="panel-header">831 <div class="panel-header">
865- <h2 class="panel-title">Usage Insights</h2>832+ <h2 class="panel-title">Jump back in</h2>
866- <p class="panel-subtitle">Your browsing patterns at a glance.</p>833+ <p class="panel-subtitle">Direct access to the sites you like the most.</p>
867 </div>834 </div>
868- <div class="panel-body insights-body">835+ <div class="panel-body quick-access-body">
869- <div class="insight-block">836+ {#if quickAccessLoading}
870- <h3 class="block-title">Top sites</h3>837+ <div class="loading">Preparing your shortcuts...</div>
871- {#if topVisitedLoading}838+ {:else if quickAccess.length === 0}
872- <div class="loading">Loading usage insights...</div>839+ <div class="empty-state compact">
873- {:else if topVisited.length === 0}840+ <h3>No quick links yet</h3>
874- <div class="empty-state compact">841+ <p>Browse a few sites and we'll surface your frequent destinations here.</p>
875- <p>No visit data available</p>842+ </div>
876- </div>843+ {:else}
877- {:else}844+ <div class="quick-access-grid-compact">
878- <div class="insights-list">845+ {#each quickAccess as item (item.id)}
879- {#each topVisited as item, index (item.id)}846+ <div
847+ class="quick-access-item-compact"
848+ role="button"
849+ tabindex="0"
850+ onclick={() => navigateTo(item.url)}
851+ onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
852+ >
853+ <div class="quick-access-favicon-compact">
854+ {#if item.favicon_url}
855+ <img
856+ src={item.favicon_url}
857+ alt=""
858+ class="quick-access-favicon-img-compact"
859+ onerror={(e) => {
860+ const target = e.target as HTMLImageElement;
861+ if (target) {
862+ target.style.display = 'none';
863+ const fallback = target.nextElementSibling as HTMLElement;
864+ if (fallback) fallback.style.display = 'flex';
865+ }
866+ }}
867+ onload={(e) => {
868+ const target = e.target as HTMLImageElement;
869+ if (target) {
870+ const fallback = target.nextElementSibling as HTMLElement;
871+ if (fallback) fallback.style.display = 'none';
872+ }
873+ }}
874+ />
875+ <div class="quick-access-fallback-compact" style="display: none;">
876+ <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">
877+ <circle cx="12" cy="12" r="10" />
878+ <path d="M2 12h20" />
879+ </svg>
880+ </div>
881+ {:else}
882+ <div class="quick-access-fallback-compact">
883+ <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">
884+ <circle cx="12" cy="12" r="10" />
885+ <path d="M2 12h20" />
886+ </svg>
887+ </div>
888+ {/if}
889+ </div>
890+ <div class="quick-access-meta-compact">
891+ <div class="quick-access-title-compact">{getDisplayTitle(item)}</div>
892+ <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
893+ </div>
894+ <div class="quick-access-actions-compact">
880 <div895 <div
881- class="insight-item"896+ class="quick-access-pin-compact"
882 role="button"897 role="button"
883 tabindex="0"898 tabindex="0"
884- onclick={() => navigateTo(item.url)}899+ onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
885- onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}900+ onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
901+ title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
886 >902 >
887- <div class="insight-rank">{index + 1}</div>903+ {#if isPinned(item.url)}
888- <div class="insight-body">904+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
889- <div class="insight-title">{getDisplayTitle(item)}</div>905+ <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"/>
890- <div class="insight-domain">{getDomain(item.url)}</div>906+ </svg>
891- </div>907+ {:else}
892- <div class="insight-visits">{formatVisitLabel(item.visit_count)}</div>908+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
909+ <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"/>
910+ </svg>
911+ {/if}
893 </div>912 </div>
894- {/each}913+ <div class="quick-access-visits-compact">{formatVisitLabel(item.visit_count)}</div>
895- </div>
896- {/if}
897- </div>
898-
899- <div class="insight-block">
900- <h3 class="block-title">History snapshot</h3>
901- {#if statsLoading}
902- <div class="loading">Loading stats...</div>
903- {:else if stats}
904- <div class="stats-grid">
905- <div class="stat-item">
906- <div class="stat-value">{stats.total_entries}</div>
907- <div class="stat-label">Entries Stored</div>
908 </div>914 </div>
909- <div class="stat-item">
910- <div class="stat-value">{stats.recent_count}</div>
911- <div class="stat-label">Recent Window</div>
912- </div>
913- {#if stats.newest_entry}
914- <div class="stat-item">
915- <div class="stat-value">{formatTime(stats.newest_entry)}</div>
916- <div class="stat-label">Newest Visit</div>
917- </div>
918- {/if}
919- {#if stats.oldest_entry}
920- <div class="stat-item">
921- <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
922- <div class="stat-label">Oldest Entry Loaded</div>
923- </div>
924- {/if}
925- </div>
926- {:else}
927- <div class="empty-state compact">
928- <p>No statistics available</p>
929 </div>915 </div>
930- {/if}916+ {/each}
931 </div>917 </div>
918+ {/if}
932 </div>919 </div>
933 </section>920 </section>
934 </div>921 </div>
@@ -953,6 +940,47 @@
953 {/if}940 {/if}
954 </div>941 </div>
955 </section>942 </section>
943+
944+
945+ <section class="stats-panel brutal-panel">
946+ <div class="panel-header">
947+ <h2 class="panel-title">Usage Stats</h2>
948+ <p class="panel-subtitle">Your browsing activity at a glance.</p>
949+ </div>
950+ <div class="panel-body">
951+ {#if statsLoading}
952+ <div class="loading">Loading stats...</div>
953+ {:else if stats}
954+ <div class="stats-grid-horizontal">
955+ <div class="stat-item-horizontal">
956+ <div class="stat-value">{stats.total_entries}</div>
957+ <div class="stat-label">Entries Stored</div>
958+ </div>
959+ <div class="stat-item-horizontal">
960+ <div class="stat-value">{stats.recent_count}</div>
961+ <div class="stat-label">Recent Window</div>
962+ </div>
963+ {#if stats.newest_entry}
964+ <div class="stat-item-horizontal">
965+ <div class="stat-value">{formatTime(stats.newest_entry)}</div>
966+ <div class="stat-label">Newest Visit</div>
967+ </div>
968+ {/if}
969+ {#if stats.oldest_entry}
970+ <div class="stat-item-horizontal">
971+ <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
972+ <div class="stat-label">Oldest Entry</div>
973+ </div>
974+ {/if}
975+ </div>
976+ {:else}
977+ <div class="empty-state compact">
978+ <p>No statistics available</p>
979+ </div>
980+ {/if}
981+ </div>
982+ </section>
983+
956 </div>984 </div>
957 985
958 <Footer />986 <Footer />
@@ -1039,126 +1067,7 @@
1039 gap: 0;1067 gap: 0;
1040 }1068 }
1041 1069
1042-.hero-panel {
1043- gap: clamp(1.25rem, 2vw, 1.75rem);
1044- padding: clamp(1.75rem, 2.2vw, 2.5rem);
1045-}
1046-
1047-.hero-heading {
1048- display: flex;
1049- flex-direction: column;
1050- gap: 0.5rem;
1051- max-width: 36rem;
1052-}
1053-
1054-.hero-title {
1055- font-size: clamp(2rem, 3vw, 2.75rem);
1056- font-weight: 700;
1057- letter-spacing: -0.01em;
1058- margin: 0;
1059-}
1060-
1061-.hero-subtitle {
1062- font-size: 1rem;
1063- line-height: 1.6;
1064- color: var(--dynamic-muted);
1065- margin: 0;
1066-}
1067-
1068-.quick-access-wrapper {
1069- display: flex;
1070- flex-direction: column;
1071- gap: 1rem;
1072-}
1073-
1074-.quick-access-grid {
1075- display: flex;
1076- flex-wrap: wrap;
1077- gap: 1rem;
1078-}
1079-
1080-.quick-access-item {
1081- flex: 1 1 min(16rem, 100%);
1082- min-width: 12rem;
1083- display: flex;
1084- align-items: center;
1085- gap: 1rem;
1086- border: 2px solid var(--dynamic-border);
1087- background-color: var(--dynamic-surface-variant);
1088- padding: 0.9rem 1.1rem;
1089- cursor: pointer;
1090- border-radius: 0;
1091- transition:
1092- border-color 200ms ease,
1093- background-color 200ms ease;
1094-}
1095-
1096-.quick-access-item:hover,
1097-.quick-access-item:focus-visible {
1098- border-color: var(--dynamic-accent);
1099- outline: none;
1100-}
1101-
1102-.quick-access-favicon {
1103- width: 48px;
1104- height: 48px;
1105- border: 2px solid var(--dynamic-border);
1106- background-color: var(--dynamic-surface);
1107- display: flex;
1108- align-items: center;
1109- justify-content: center;
1110- flex-shrink: 0;
1111- border-radius: 0;
1112-}
1113-
1114-.quick-access-favicon-img {
1115- width: 70%;
1116- height: 70%;
1117- object-fit: contain;
1118-}
1119-
1120-.quick-access-fallback {
1121- width: 100%;
1122- height: 100%;
1123- display: flex;
1124- align-items: center;
1125- justify-content: center;
1126- color: var(--dynamic-muted);
1127-}
1128-
1129-.quick-access-meta {
1130- flex: 1;
1131- min-width: 0;
1132- display: flex;
1133- flex-direction: column;
1134- gap: 0.125rem;
1135-}
1136-
1137-.quick-access-title {
1138- font-size: 1rem;
1139- font-weight: 600;
1140- color: var(--dynamic-text);
1141- white-space: nowrap;
1142- overflow: hidden;
1143- text-overflow: ellipsis;
1144-}
1145 1070
1146-.quick-access-domain {
1147- font-size: 0.85rem;
1148- color: var(--dynamic-muted);
1149- white-space: nowrap;
1150- overflow: hidden;
1151- text-overflow: ellipsis;
1152-}
1153-
1154-.quick-access-visits {
1155- font-size: 0.82rem;
1156- color: var(--dynamic-accent);
1157- text-transform: uppercase;
1158- letter-spacing: 0.05em;
1159- font-weight: 600;
1160- flex-shrink: 0;
1161-}
1162 1071
1163 .loading {1072 .loading {
1164 padding: 1.25rem;1073 padding: 1.25rem;
@@ -1211,7 +1120,7 @@
1211 min-height: 0;1120 min-height: 0;
1212 }1121 }
1213 1122
1214-.insights-panel {1123+.quick-access-panel {
1215 flex: 1 1 35%;1124 flex: 1 1 35%;
1216 min-width: min(24rem, 100%);1125 min-width: min(24rem, 100%);
1217 display: flex;1126 display: flex;
@@ -1264,28 +1173,31 @@
1264 flex: 1;1173 flex: 1;
1265 min-height: 0;1174 min-height: 0;
1266 overflow-y: auto;1175 overflow-y: auto;
1267- padding-right: 0.5rem;1176+ background-color: var(--dynamic-surface-variant);
1177+ border: 2px solid var(--dynamic-border);
1178+ padding: 0.5rem;
1268 }1179 }
1269 1180
1270 .history-item {1181 .history-item {
1271 display: flex;1182 display: flex;
1272 align-items: flex-start;1183 align-items: flex-start;
1273- gap: 1rem;1184+ gap: 0.75rem;
1274- border: 2px solid var(--dynamic-border);1185+ border-bottom: 1px solid var(--dynamic-border);
1275 background-color: var(--dynamic-surface-variant);1186 background-color: var(--dynamic-surface-variant);
1276- padding: 1rem 1.1rem;1187+ padding: 0.75rem 0.5rem;
1277 cursor: pointer;1188 cursor: pointer;
1278- min-height: 80px;
1279- border-radius: 0;
1280 transition:1189 transition:
1281- border-color 200ms ease,1190+ background-color 200ms ease,
1282- background-color 200ms ease;1191+ border-color 200ms ease;
1192+}
1193+
1194+.history-item:last-child {
1195+ border-bottom: none;
1283 }1196 }
1284 1197
1285 .history-item:hover,1198 .history-item:hover,
1286 .history-item:focus-visible {1199 .history-item:focus-visible {
1287- border-color: var(--dynamic-accent);1200+ background-color: var(--dynamic-surface);
1288- background-color: var(--dynamic-bg);
1289 outline: none;1201 outline: none;
1290 }1202 }
1291 1203
@@ -1327,11 +1239,12 @@
1327 min-width: 0;1239 min-width: 0;
1328 display: flex;1240 display: flex;
1329 flex-direction: column;1241 flex-direction: column;
1330- gap: 0.125rem;1242+ gap: 0.25rem;
1331 justify-content: center;1243 justify-content: center;
1244+ overflow: hidden;
1332 }1245 }
1333 1246
1334-.history-item-top {1247+.history-item-main {
1335 display: flex;1248 display: flex;
1336 align-items: flex-start;1249 align-items: flex-start;
1337 justify-content: space-between;1250 justify-content: space-between;
@@ -1343,31 +1256,19 @@
1343 color: var(--dynamic-text);1256 color: var(--dynamic-text);
1344 white-space: nowrap;1257 white-space: nowrap;
1345 overflow: hidden;1258 overflow: hidden;
1346- text-overflow: ellipsis;1259+ flex: 1;
1260+ position: relative;
1261+ mask-image: linear-gradient(to right, black 70%, transparent 100%);
1262+ -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1347 }1263 }
1348 1264
1265+
1349 .history-time {1266 .history-time {
1350 font-size: 0.85rem;1267 font-size: 0.85rem;
1351 color: var(--dynamic-muted);1268 color: var(--dynamic-muted);
1352 flex-shrink: 0;1269 flex-shrink: 0;
1353 }1270 }
1354 1271
1355-.history-item-bottom {
1356- display: flex;
1357- align-items: baseline;
1358- gap: 0.5rem;
1359- overflow: hidden;
1360- position: relative;
1361-}
1362-
1363-.history-domain {
1364- font-size: 0.9rem;
1365- font-weight: 500;
1366- color: var(--dynamic-text);
1367- white-space: nowrap;
1368- flex-shrink: 0;
1369-}
1370-
1371 .history-url {1272 .history-url {
1372 font-size: 0.8rem;1273 font-size: 0.8rem;
1373 color: var(--dynamic-muted);1274 color: var(--dynamic-muted);
@@ -1379,11 +1280,41 @@
1379 monospace;1280 monospace;
1380 overflow: hidden;1281 overflow: hidden;
1381 flex: 1;1282 flex: 1;
1283+ min-width: 0;
1382 position: relative;1284 position: relative;
1383 mask-image: linear-gradient(to right, black 70%, transparent 100%);1285 mask-image: linear-gradient(to right, black 70%, transparent 100%);
1384 -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);1286 -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1385 }1287 }
1386 1288
1289+.history-url :global(.history-url-domain) {
1290+ color: var(--dynamic-text) !important;
1291+ font-weight: 600;
1292+}
1293+
1294+.history-actions {
1295+ display: flex;
1296+ flex-shrink: 0;
1297+}
1298+
1299+.history-pin {
1300+ display: flex;
1301+ align-items: center;
1302+ justify-content: center;
1303+ width: 38px;
1304+ border-left: 2px solid var(--dynamic-border);
1305+ background-color: var(--dynamic-surface);
1306+ color: var(--dynamic-muted);
1307+ transition: background-color 200ms ease, color 200ms ease;
1308+ cursor: pointer;
1309+}
1310+
1311+.history-pin:hover,
1312+.history-pin:focus-visible {
1313+ color: var(--dynamic-accent);
1314+ background-color: var(--dynamic-bg);
1315+ outline: none;
1316+}
1317+
1387 .history-delete {1318 .history-delete {
1388 display: flex;1319 display: flex;
1389 align-items: center;1320 align-items: center;
@@ -1416,66 +1347,68 @@
1416 font-size: 0.85rem;1347 font-size: 0.85rem;
1417 }1348 }
1418 1349
1419-.insights-body {1350+.quick-access-body {
1420- gap: 1.5rem;1351+ gap: 1rem;
1421-}
1422-
1423-.insight-block {
1424- display: flex;
1425- flex-direction: column;
1426- gap: 0.9rem;
1427-}
1428-
1429-.block-title {
1430- font-size: 0.85rem;
1431- text-transform: uppercase;
1432- letter-spacing: 0.08em;
1433- color: var(--dynamic-muted);
1434- margin: 0;
1435 }1352 }
1436 1353
1437-.insights-list {1354+.quick-access-grid-compact {
1438 display: flex;1355 display: flex;
1439 flex-direction: column;1356 flex-direction: column;
1440- gap: 0.75rem;1357+ gap: 0.5rem;
1358+ background-color: var(--dynamic-surface-variant);
1359+ border: 2px solid var(--dynamic-border);
1360+ padding: 0.5rem;
1441 }1361 }
1442 1362
1443-.insight-item {1363+.quick-access-item-compact {
1444 display: flex;1364 display: flex;
1445 align-items: center;1365 align-items: center;
1446 gap: 0.75rem;1366 gap: 0.75rem;
1447- border: 2px solid var(--dynamic-border);1367+ border-bottom: 1px solid var(--dynamic-border);
1448 background-color: var(--dynamic-surface-variant);1368 background-color: var(--dynamic-surface-variant);
1449- padding: 0.85rem 1rem;1369+ padding: 0.5rem 0.25rem;
1450 cursor: pointer;1370 cursor: pointer;
1451- border-radius: 0;1371+ transition: background-color 200ms ease;
1452- transition:
1453- border-color 200ms ease,
1454- background-color 200ms ease;
1455 }1372 }
1456 1373
1457-.insight-item:hover,1374+.quick-access-item-compact:last-child {
1458-.insight-item:focus-visible {1375+ border-bottom: none;
1459- border-color: var(--dynamic-accent);1376+}
1377+
1378+.quick-access-item-compact:hover,
1379+.quick-access-item-compact:focus-visible {
1380+ background-color: var(--dynamic-surface);
1460 outline: none;1381 outline: none;
1461 }1382 }
1462 1383
1463-.insight-rank {1384+.quick-access-favicon-compact {
1464- width: 2rem;1385+ width: 32px;
1465- height: 2rem;1386+ height: 32px;
1466 border: 2px solid var(--dynamic-border);1387 border: 2px solid var(--dynamic-border);
1467- background-color: var(--dynamic-surface);1388+ background-color: var(--dynamic-surface-variant);
1468 display: flex;1389 display: flex;
1469 align-items: center;1390 align-items: center;
1470 justify-content: center;1391 justify-content: center;
1471- font-weight: 600;
1472- font-size: 0.9rem;
1473- color: var(--dynamic-accent);
1474 flex-shrink: 0;1392 flex-shrink: 0;
1475 border-radius: 0;1393 border-radius: 0;
1476 }1394 }
1477 1395
1478-.insight-body {1396+.quick-access-favicon-img-compact {
1397+ width: 70%;
1398+ height: 70%;
1399+ object-fit: contain;
1400+}
1401+
1402+.quick-access-fallback-compact {
1403+ width: 100%;
1404+ height: 100%;
1405+ display: flex;
1406+ align-items: center;
1407+ justify-content: center;
1408+ color: var(--dynamic-muted);
1409+}
1410+
1411+.quick-access-meta-compact {
1479 flex: 1;1412 flex: 1;
1480 min-width: 0;1413 min-width: 0;
1481 display: flex;1414 display: flex;
@@ -1483,48 +1416,63 @@
1483 gap: 0.125rem;1416 gap: 0.125rem;
1484 }1417 }
1485 1418
1486-.insight-title {1419+.quick-access-title-compact {
1420+ font-size: 0.95rem;
1487 font-weight: 600;1421 font-weight: 600;
1488 color: var(--dynamic-text);1422 color: var(--dynamic-text);
1489 white-space: nowrap;1423 white-space: nowrap;
1490 overflow: hidden;1424 overflow: hidden;
1491- text-overflow: ellipsis;1425+ position: relative;
1426+ mask-image: linear-gradient(to right, black 70%, transparent 100%);
1427+ -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1492 }1428 }
1493 1429
1494-.insight-domain {1430+.quick-access-domain-compact {
1495- font-size: 0.85rem;1431+ font-size: 0.8rem;
1496 color: var(--dynamic-muted);1432 color: var(--dynamic-muted);
1497 white-space: nowrap;1433 white-space: nowrap;
1498 overflow: hidden;1434 overflow: hidden;
1499 text-overflow: ellipsis;1435 text-overflow: ellipsis;
1500 }1436 }
1501 1437
1502-.insight-visits {1438+.quick-access-actions-compact {
1503- font-size: 0.78rem;1439+ display: flex;
1504- color: var(--dynamic-accent);1440+ align-items: center;
1505- text-transform: uppercase;1441+ gap: 0.5rem;
1506- letter-spacing: 0.05em;
1507- font-weight: 600;
1508 flex-shrink: 0;1442 flex-shrink: 0;
1509 }1443 }
1510 1444
1511-.stats-grid {1445+.quick-access-pin-compact {
1512 display: flex;1446 display: flex;
1513- flex-wrap: wrap;1447+ align-items: center;
1514- gap: 0.85rem;1448+ justify-content: center;
1515-}1449+ width: 24px;
1516-1450+ height: 24px;
1517-.stat-item {1451+ border: 1px solid var(--dynamic-border);
1518- flex: 1 1 160px;
1519- border: 2px solid var(--dynamic-border);
1520 background-color: var(--dynamic-surface-variant);1452 background-color: var(--dynamic-surface-variant);
1521- padding: 1rem;1453+ color: var(--dynamic-muted);
1522- display: flex;1454+ transition: background-color 200ms ease, color 200ms ease;
1523- flex-direction: column;1455+ cursor: pointer;
1524- gap: 0.35rem;
1525 border-radius: 0;1456 border-radius: 0;
1526 }1457 }
1527 1458
1459+.quick-access-pin-compact:hover,
1460+.quick-access-pin-compact:focus-visible {
1461+ color: var(--dynamic-accent);
1462+ background-color: var(--dynamic-surface);
1463+ outline: none;
1464+}
1465+
1466+.quick-access-visits-compact {
1467+ font-size: 0.75rem;
1468+ color: var(--dynamic-accent);
1469+ text-transform: uppercase;
1470+ letter-spacing: 0.05em;
1471+ font-weight: 600;
1472+ flex-shrink: 0;
1473+}
1474+
1475+
1528 .stat-value {1476 .stat-value {
1529 font-size: 1.4rem;1477 font-size: 1.4rem;
1530 font-weight: 600;1478 font-weight: 600;
@@ -1538,6 +1486,26 @@
1538 letter-spacing: 0.08em;1486 letter-spacing: 0.08em;
1539 }1487 }
1540 1488
1489+.stats-grid-horizontal {
1490+ display: flex;
1491+ flex-wrap: wrap;
1492+ gap: 1rem;
1493+ justify-content: space-between;
1494+}
1495+
1496+.stat-item-horizontal {
1497+ flex: 1;
1498+ min-width: 120px;
1499+ border: 2px solid var(--dynamic-border);
1500+ background-color: var(--dynamic-surface-variant);
1501+ padding: 0.75rem;
1502+ display: flex;
1503+ flex-direction: column;
1504+ gap: 0.25rem;
1505+ text-align: center;
1506+ border-radius: 0;
1507+}
1508+
1541 .shortcuts-panel {1509 .shortcuts-panel {
1542 gap: 0;1510 gap: 0;
1543 }1511 }
@@ -1587,7 +1555,7 @@
1587 /* Responsive Design */1555 /* Responsive Design */
1588 @media (max-width: 1200px) {1556 @media (max-width: 1200px) {
1589 .history-panel,1557 .history-panel,
1590- .insights-panel {1558+ .quick-access-panel {
1591 min-width: min(28rem, 100%);1559 min-width: min(28rem, 100%);
1592 }1560 }
1593 }1561 }
@@ -1602,13 +1570,12 @@
1602 }1570 }
1603 1571
1604 .history-panel,1572 .history-panel,
1605- .insights-panel {1573+ .quick-access-panel {
1606 min-width: 100%;1574 min-width: 100%;
1575+ max-width: 100%;
1576+ width: 100%;
1607 }1577 }
1608 1578
1609- .quick-access-item {
1610- flex: 1 1 calc(50% - 1rem);
1611- }
1612 }1579 }
1613 1580
1614 @media (max-width: 640px) {1581 @media (max-width: 640px) {
@@ -1618,29 +1585,33 @@
1618 }1585 }
1619 1586
1620 .panel-body,1587 .panel-body,
1621- .panel-header,1588+ .panel-header {
1622- .hero-panel {
1623 padding-left: 1.25rem;1589 padding-left: 1.25rem;
1624 padding-right: 1.25rem;1590 padding-right: 1.25rem;
1625 }1591 }
1626 1592
1627- .quick-access-item {
1628- flex: 1 1 100%;
1629- }
1630 1593
1631 .history-item {1594 .history-item {
1632 flex-direction: column;1595 flex-direction: column;
1633 align-items: flex-start;1596 align-items: flex-start;
1634 }1597 }
1635 1598
1636- .history-delete {1599+ .history-actions {
1637 width: 100%;1600 width: 100%;
1638- border-left: 0;
1639 border-top: 2px solid var(--dynamic-border);1601 border-top: 2px solid var(--dynamic-border);
1640- align-self: stretch;
1641 }1602 }
1642 1603
1643- .history-item-top {1604+ .history-pin,
1605+ .history-delete {
1606+ flex: 1;
1607+ border-left: 0;
1608+ }
1609+
1610+ .history-pin {
1611+ border-right: 1px solid var(--dynamic-border);
1612+ }
1613+
1614+ .history-item-main {
1644 flex-direction: column;1615 flex-direction: column;
1645 align-items: flex-start;1616 align-items: flex-start;
1646 gap: 0.35rem;1617 gap: 0.35rem;
@@ -1649,5 +1620,37 @@
1649 .history-time {1620 .history-time {
1650 order: 3;1621 order: 3;
1651 }1622 }
1623+
1624+ .stats-grid-horizontal {
1625+ flex-direction: column;
1626+ gap: 0.75rem;
1627+ }
1628+
1629+ .stat-item-horizontal {
1630+ min-width: 100%;
1631+ }
1632+}
1633+
1634+.show-more-button {
1635+ width: 100%;
1636+ padding: 0.75rem 1rem;
1637+ border: 2px solid var(--dynamic-border);
1638+ background-color: var(--dynamic-surface-variant);
1639+ color: var(--dynamic-text);
1640+ font-size: 0.9rem;
1641+ font-weight: 500;
1642+ cursor: pointer;
1643+ transition:
1644+ background-color 200ms ease,
1645+ border-color 200ms ease;
1646+ border-radius: 0;
1647+ margin-top: 0.5rem;
1648+}
1649+
1650+.show-more-button:hover,
1651+.show-more-button:focus-visible {
1652+ background-color: var(--dynamic-surface);
1653+ border-color: var(--dynamic-accent);
1654+ outline: none;
1652 }1655 }
1653 </style>1656 </style>

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