HistoryTimeline.svelte RawHistoryFolder
1<script lang="ts">
2 import type { TimelineGroup, HistoryEntry } from '../types';
3 import { homepageState } from '../state.svelte';
4 import HistoryItem from './HistoryItem.svelte';
5
6 interface Props {
7 groups: TimelineGroup[];
8 onSelectEntry?: (entry: HistoryEntry) => void;
9 onDeleteEntry?: (entry: HistoryEntry) => void;
10 onLoadMore?: () => void;
11 }
12
13 let { groups, onSelectEntry, onDeleteEntry, onLoadMore }: Props = $props();
14
15 let containerRef = $state<HTMLElement | null>(null);
16 let sentinelRef = $state<HTMLElement | null>(null);
17
18 // Flatten groups to get global index for keyboard navigation
19 const flatEntries = $derived(() => {
20 const entries: HistoryEntry[] = [];
21 for (const group of groups) {
22 entries.push(...group.entries);
23 }
24 return entries;
25 });
26
27 // Track which entry is focused based on global focusedIndex
28 const getFocusedEntry = () => {
29 const entries = flatEntries();
30 return entries[homepageState.focusedIndex] ?? null;
31 };
32
33 // Track previous focused index to only scroll on actual navigation
34 let prevFocusedIndex = $state(-1);
35
36 // Scroll focused item into view when focusedIndex changes via keyboard navigation
37 $effect(() => {
38 // Skip if panel not active or command palette is open
39 if (!containerRef || homepageState.activePanel !== 'history') return;
40 if (homepageState.commandPaletteOpen) return;
41
42 const currentIndex = homepageState.focusedIndex;
43
44 // Only scroll if index actually changed (not on initial render or modal close)
45 if (prevFocusedIndex === currentIndex) return;
46 if (prevFocusedIndex === -1) {
47 // First render - just track, don't scroll
48 prevFocusedIndex = currentIndex;
49 return;
50 }
51
52 prevFocusedIndex = currentIndex;
53
54 const focusedEntry = getFocusedEntry();
55 if (!focusedEntry) return;
56
57 // Find the focused element by entry ID
58 const el = containerRef.querySelector(`[data-entry-id="${focusedEntry.id}"]`);
59 if (el) {
60 el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
61 }
62 });
63
64 // Setup infinite scroll observer
65 $effect(() => {
66 if (!sentinelRef || !onLoadMore) return;
67
68 const observer = new IntersectionObserver(
69 (entries) => {
70 const entry = entries[0];
71 if (entry?.isIntersecting && homepageState.hasMoreHistory && !homepageState.historyLoading) {
72 onLoadMore();
73 }
74 },
75 { threshold: 0.1, rootMargin: '100px' }
76 );
77
78 observer.observe(sentinelRef);
79
80 return () => observer.disconnect();
81 });
82</script>
83
84<div class="history-timeline" bind:this={containerRef}>
85 {#if groups.length === 0}
86 <div class="empty-state">
87 <span class="empty-icon"></span>
88 <span class="empty-text">NO HISTORY RECORDED</span>
89 <span class="empty-hint">Start browsing to see your history here</span>
90 </div>
91 {:else}
92 {#each groups as group (group.date)}
93 <div class="timeline-group">
94 <div class="group-header">
95 <span class="group-label">{group.label}</span>
96 <span class="group-count">{group.entries.length}</span>
97 </div>
98 <div class="group-entries">
99 {#each group.entries as entry (entry.id)}
100 <HistoryItem
101 {entry}
102 focused={getFocusedEntry()?.id === entry.id}
103 onSelect={onSelectEntry}
104 onDelete={onDeleteEntry}
105 />
106 {/each}
107 </div>
108 </div>
109 {/each}
110
111 {#if homepageState.hasMoreHistory}
112 <div class="load-more-sentinel" bind:this={sentinelRef}>
113 {#if homepageState.historyLoading}
114 <span class="loading-text">LOADING...</span>
115 {/if}
116 </div>
117 {/if}
118 {/if}
119</div>
120
121<style>
122 .history-timeline {
123 display: flex;
124 flex-direction: column;
125 }
126
127 .empty-state {
128 flex: 1;
129 display: flex;
130 flex-direction: column;
131 align-items: center;
132 justify-content: center;
133 gap: 0.5rem;
134 padding: 2rem;
135 color: var(--muted-foreground);
136 text-align: center;
137 }
138
139 .empty-icon {
140 font-size: 1.5rem;
141 opacity: 0.5;
142 }
143
144 .empty-text {
145 font-size: 0.75rem;
146 font-weight: 600;
147 letter-spacing: 0.12em;
148 }
149
150 .empty-hint {
151 font-size: 0.7rem;
152 letter-spacing: 0.08em;
153 opacity: 0.7;
154 }
155
156 .timeline-group {
157 border-bottom-width: 1px;
158 border-bottom-style: solid;
159 border-bottom-color: var(--border);
160 }
161
162 .timeline-group:last-child {
163 border-bottom: none;
164 }
165
166 .group-header {
167 display: flex;
168 align-items: center;
169 justify-content: space-between;
170 gap: 0.75rem;
171 padding: 0.5rem 0.85rem;
172 background: color-mix(in srgb, var(--background) 80%, var(--card) 20%);
173 border-bottom-width: 1px;
174 border-bottom-style: solid;
175 border-bottom-color: color-mix(in srgb, var(--border) 50%, transparent);
176 position: sticky;
177 top: 0;
178 z-index: 1;
179 }
180
181 .group-label {
182 font-size: 0.68rem;
183 font-weight: 600;
184 text-transform: uppercase;
185 letter-spacing: 0.12em;
186 color: var(--foreground);
187 }
188
189 .group-count {
190 font-size: 0.6rem;
191 padding: 0.15rem 0.4rem;
192 border-width: 1px;
193 border-style: solid;
194 border-color: var(--border);
195 background: var(--background);
196 color: var(--muted-foreground);
197 }
198
199 .group-entries {
200 display: flex;
201 flex-direction: column;
202 }
203
204 .load-more-sentinel {
205 padding: 0.75rem;
206 display: flex;
207 align-items: center;
208 justify-content: center;
209 min-height: 40px;
210 }
211
212 .loading-text {
213 font-size: 0.65rem;
214 color: var(--muted-foreground);
215 letter-spacing: 0.12em;
216 animation: pulse 1.5s ease-in-out infinite;
217 }
218
219 @keyframes pulse {
220 0%, 100% { opacity: 0.4; }
221 50% { opacity: 1; }
222 }
223</style>