fix(gui): add missing @lucide/svelte dependency to fix runtime error
- commit
- 3ef94184479a5a4930dda0af542e3f93bf7a7037
- author
- bamen · · 2025-09-15 14:03 UTC
- parent
- 3aefd86
- signature
- unsigned
9 files · +228 -35
M gui/package-lock.json +12 -18 generated file: collapsed
M gui/package.json +3 -0
| @@ -29,5 +29,8 @@ | |||
| 29 | 29 | "typescript": "^5.3.0", | |
| 30 | 30 | "typescript-eslint": "^8.43.0", | |
| 31 | 31 | "vite": "^5.0.0" | |
| 32 | + | }, | |
| 33 | + | "dependencies": { | |
| 34 | + | "@lucide/svelte": "^0.544.0" | |
| 32 | 35 | } | |
| 33 | 36 | } | |
| @@ -29,5 +29,8 @@ | |||||
| 29 | "typescript": "^5.3.0", | 29 | "typescript": "^5.3.0", | ||
| 30 | "typescript-eslint": "^8.43.0", | 30 | "typescript-eslint": "^8.43.0", | ||
| 31 | "vite": "^5.0.0" | 31 | "vite": "^5.0.0" | ||
| 32 | + | }, | |||
| 33 | + | "dependencies": { | |||
| 34 | + | "@lucide/svelte": "^0.544.0" | |||
| 32 | } | 35 | } | ||
| 33 | } | 36 | } | ||
M gui/src/pages/Homepage.svelte +101 -12
| @@ -33,7 +33,13 @@ | |||
| 33 | 33 | let topVisitedLoading = $state(true); | |
| 34 | 34 | let shortcutsLoading = $state(true); | |
| 35 | 35 | let statsLoading = $state(true); | |
| 36 | - | let deletingIds = $state<Set<number>>(new Set()); | |
| 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); | |
| 37 | 43 | ||
| 38 | 44 | // Initialize shortcuts (these are browser shortcuts, not dynamic from API) | |
| 39 | 45 | const initializeShortcuts = () => { | |
| @@ -109,18 +115,50 @@ | |||
| 109 | 115 | if (response.ok) { | |
| 110 | 116 | const data = await response.json(); | |
| 111 | 117 | historyItems = Array.isArray(data) ? data : []; | |
| 118 | + | historyOffset = historyItems.length; | |
| 119 | + | hasMoreHistory = data.length === 20; // If we got full batch, there might be more | |
| 112 | 120 | } else { | |
| 113 | 121 | console.warn('Failed to fetch history:', response.status); | |
| 114 | 122 | historyItems = []; | |
| 123 | + | hasMoreHistory = false; | |
| 115 | 124 | } | |
| 116 | 125 | } catch (error) { | |
| 117 | 126 | console.error('Error fetching history:', error); | |
| 118 | 127 | historyItems = []; | |
| 128 | + | hasMoreHistory = false; | |
| 119 | 129 | } finally { | |
| 120 | 130 | historyLoading = false; | |
| 121 | 131 | } | |
| 122 | 132 | }; | |
| 123 | 133 | ||
| 134 | + | // Load more history items for infinite scroll | |
| 135 | + | const loadMoreHistory = async () => { | |
| 136 | + | if (loadingMoreHistory || !hasMoreHistory) return; | |
| 137 | + | ||
| 138 | + | loadingMoreHistory = true; | |
| 139 | + | try { | |
| 140 | + | const response = await fetch(`dumb://homepage/api/history/recent?limit=20&offset=${historyOffset}`); | |
| 141 | + | if (response.ok) { | |
| 142 | + | const data = await response.json(); | |
| 143 | + | if (Array.isArray(data) && data.length > 0) { | |
| 144 | + | historyItems = [...historyItems, ...data]; | |
| 145 | + | historyOffset += data.length; | |
| 146 | + | hasMoreHistory = data.length === 20; // If we got full batch, there might be more | |
| 147 | + | } else { | |
| 148 | + | hasMoreHistory = false; | |
| 149 | + | } | |
| 150 | + | } else { | |
| 151 | + | console.warn('Failed to fetch more history:', response.status); | |
| 152 | + | hasMoreHistory = false; | |
| 153 | + | } | |
| 154 | + | } catch (error) { | |
| 155 | + | console.error('Error fetching more history:', error); | |
| 156 | + | hasMoreHistory = false; | |
| 157 | + | } finally { | |
| 158 | + | loadingMoreHistory = false; | |
| 159 | + | } | |
| 160 | + | }; | |
| 161 | + | ||
| 124 | 162 | // Navigate to a URL when history item is clicked | |
| 125 | 163 | const navigateTo = (url: string) => { | |
| 126 | 164 | window.location.href = url; | |
| @@ -130,8 +168,10 @@ | |||
| 130 | 168 | const deleteHistoryEntry = async (id: number, event: Event) => { | |
| 131 | 169 | event.stopPropagation(); // Prevent navigation | |
| 132 | 170 | ||
| 133 | - | // Add to deleting set for animation | |
| 134 | - | deletingIds = new Set([...deletingIds, id]); | |
| 171 | + | // Add to deleting array for animation | |
| 172 | + | if (!deletingIds.includes(id)) { | |
| 173 | + | deletingIds = [...deletingIds, id]; | |
| 174 | + | } | |
| 135 | 175 | ||
| 136 | 176 | try { | |
| 137 | 177 | const response = await fetch(`dumb://homepage/api/history/delete?id=${id}`); | |
| @@ -139,23 +179,25 @@ | |||
| 139 | 179 | // Wait a bit for the fade animation to complete | |
| 140 | 180 | await new Promise(resolve => setTimeout(resolve, 200)); | |
| 141 | 181 | ||
| 142 | - | // Refresh history after successful deletion | |
| 182 | + | // Reset pagination and refresh history after successful deletion | |
| 183 | + | historyOffset = 0; | |
| 184 | + | hasMoreHistory = true; | |
| 143 | 185 | await fetchHistory(); | |
| 144 | 186 | await fetchStats(); | |
| 145 | 187 | await fetchTopVisited(); | |
| 146 | 188 | } else { | |
| 147 | 189 | console.error('Failed to delete history entry:', response.status); | |
| 148 | - | // Remove from deleting set if failed | |
| 149 | - | deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id)); | |
| 190 | + | // Remove from deleting array if failed | |
| 191 | + | deletingIds = deletingIds.filter(i => i !== id); | |
| 150 | 192 | } | |
| 151 | 193 | } catch (error) { | |
| 152 | 194 | console.error('Error deleting history entry:', error); | |
| 153 | - | // Remove from deleting set if failed | |
| 154 | - | deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id)); | |
| 195 | + | // Remove from deleting array if failed | |
| 196 | + | deletingIds = deletingIds.filter(i => i !== id); | |
| 155 | 197 | } | |
| 156 | 198 | ||
| 157 | - | // Remove from deleting set after refresh | |
| 158 | - | deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id)); | |
| 199 | + | // Remove from deleting array after refresh | |
| 200 | + | deletingIds = deletingIds.filter(i => i !== id); | |
| 159 | 201 | }; | |
| 160 | 202 | ||
| 161 | 203 | // Get domain from URL | |
| @@ -227,9 +269,36 @@ | |||
| 227 | 269 | } | |
| 228 | 270 | }; | |
| 229 | 271 | ||
| 272 | + | // Intersection Observer for infinite scroll | |
| 273 | + | let historyScrollSentinel = $state<HTMLElement | null>(null); | |
| 274 | + | ||
| 275 | + | const setupInfiniteScroll = () => { | |
| 276 | + | if (!historyScrollSentinel) return; | |
| 277 | + | ||
| 278 | + | const observer = new IntersectionObserver( | |
| 279 | + | (entries) => { | |
| 280 | + | const entry = entries[0]; | |
| 281 | + | if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) { | |
| 282 | + | loadMoreHistory(); | |
| 283 | + | } | |
| 284 | + | }, | |
| 285 | + | { | |
| 286 | + | threshold: 0.1, | |
| 287 | + | rootMargin: '50px' | |
| 288 | + | } | |
| 289 | + | ); | |
| 290 | + | ||
| 291 | + | observer.observe(historyScrollSentinel); | |
| 292 | + | ||
| 293 | + | return () => observer.disconnect(); | |
| 294 | + | }; | |
| 295 | + | ||
| 230 | 296 | onMount(() => { | |
| 231 | 297 | initializeShortcuts(); | |
| 232 | - | fetchHistory(); | |
| 298 | + | fetchHistory().then(() => { | |
| 299 | + | // Setup infinite scroll after initial history is loaded | |
| 300 | + | setTimeout(setupInfiniteScroll, 100); | |
| 301 | + | }); | |
| 233 | 302 | fetchStats(); | |
| 234 | 303 | fetchTopVisited(); | |
| 235 | 304 | }); | |
| @@ -252,7 +321,7 @@ | |||
| 252 | 321 | {#each historyItems as item (item.url)} | |
| 253 | 322 | <div | |
| 254 | 323 | class="history-item" | |
| 255 | - | class:deleting={deletingIds.has(item.id)} | |
| 324 | + | class:deleting={isDeleting(item.id)} | |
| 256 | 325 | role="button" | |
| 257 | 326 | tabindex="0" | |
| 258 | 327 | onclick={() => navigateTo(item.url)} | |
| @@ -309,6 +378,14 @@ | |||
| 309 | 378 | </div> | |
| 310 | 379 | </div> | |
| 311 | 380 | {/each} | |
| 381 | + | ||
| 382 | + | <!-- Infinite scroll sentinel and loading indicator --> | |
| 383 | + | {#if hasMoreHistory} | |
| 384 | + | <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div> | |
| 385 | + | {#if loadingMoreHistory} | |
| 386 | + | <div class="loading-more">Loading more history...</div> | |
| 387 | + | {/if} | |
| 388 | + | {/if} | |
| 312 | 389 | {/if} | |
| 313 | 390 | </div> | |
| 314 | 391 | </div> | |
| @@ -725,6 +802,18 @@ | |||
| 725 | 802 | color: #888; | |
| 726 | 803 | } | |
| 727 | 804 | ||
| 805 | + | .scroll-sentinel { | |
| 806 | + | height: 1px; | |
| 807 | + | width: 100%; | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | .loading-more { | |
| 811 | + | padding: 1rem; | |
| 812 | + | text-align: center; | |
| 813 | + | color: #888; | |
| 814 | + | font-size: 0.9rem; | |
| 815 | + | } | |
| 816 | + | ||
| 728 | 817 | .empty-state { | |
| 729 | 818 | padding: 3rem 2rem; | |
| 730 | 819 | text-align: center; | |
| @@ -33,7 +33,13 @@ | |||||
| 33 | let topVisitedLoading = $state(true); | 33 | let topVisitedLoading = $state(true); | ||
| 34 | let shortcutsLoading = $state(true); | 34 | let shortcutsLoading = $state(true); | ||
| 35 | let statsLoading = $state(true); | 35 | let statsLoading = $state(true); | ||
| 36 | - | let deletingIds = $state<Set<number>>(new Set()); | 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); | |||
| 37 | 43 | ||||
| 38 | // Initialize shortcuts (these are browser shortcuts, not dynamic from API) | 44 | // Initialize shortcuts (these are browser shortcuts, not dynamic from API) | ||
| 39 | const initializeShortcuts = () => { | 45 | const initializeShortcuts = () => { | ||
| @@ -109,18 +115,50 @@ | |||||
| 109 | if (response.ok) { | 115 | if (response.ok) { | ||
| 110 | const data = await response.json(); | 116 | const data = await response.json(); | ||
| 111 | historyItems = Array.isArray(data) ? data : []; | 117 | historyItems = Array.isArray(data) ? data : []; | ||
| 118 | + | historyOffset = historyItems.length; | |||
| 119 | + | hasMoreHistory = data.length === 20; // If we got full batch, there might be more | |||
| 112 | } else { | 120 | } else { | ||
| 113 | console.warn('Failed to fetch history:', response.status); | 121 | console.warn('Failed to fetch history:', response.status); | ||
| 114 | historyItems = []; | 122 | historyItems = []; | ||
| 123 | + | hasMoreHistory = false; | |||
| 115 | } | 124 | } | ||
| 116 | } catch (error) { | 125 | } catch (error) { | ||
| 117 | console.error('Error fetching history:', error); | 126 | console.error('Error fetching history:', error); | ||
| 118 | historyItems = []; | 127 | historyItems = []; | ||
| 128 | + | hasMoreHistory = false; | |||
| 119 | } finally { | 129 | } finally { | ||
| 120 | historyLoading = false; | 130 | historyLoading = false; | ||
| 121 | } | 131 | } | ||
| 122 | }; | 132 | }; | ||
| 123 | 133 | ||||
| 134 | + | // Load more history items for infinite scroll | |||
| 135 | + | const loadMoreHistory = async () => { | |||
| 136 | + | if (loadingMoreHistory || !hasMoreHistory) return; | |||
| 137 | + | ||||
| 138 | + | loadingMoreHistory = true; | |||
| 139 | + | try { | |||
| 140 | + | const response = await fetch(`dumb://homepage/api/history/recent?limit=20&offset=${historyOffset}`); | |||
| 141 | + | if (response.ok) { | |||
| 142 | + | const data = await response.json(); | |||
| 143 | + | if (Array.isArray(data) && data.length > 0) { | |||
| 144 | + | historyItems = [...historyItems, ...data]; | |||
| 145 | + | historyOffset += data.length; | |||
| 146 | + | hasMoreHistory = data.length === 20; // If we got full batch, there might be more | |||
| 147 | + | } else { | |||
| 148 | + | hasMoreHistory = false; | |||
| 149 | + | } | |||
| 150 | + | } else { | |||
| 151 | + | console.warn('Failed to fetch more history:', response.status); | |||
| 152 | + | hasMoreHistory = false; | |||
| 153 | + | } | |||
| 154 | + | } catch (error) { | |||
| 155 | + | console.error('Error fetching more history:', error); | |||
| 156 | + | hasMoreHistory = false; | |||
| 157 | + | } finally { | |||
| 158 | + | loadingMoreHistory = false; | |||
| 159 | + | } | |||
| 160 | + | }; | |||
| 161 | + | ||||
| 124 | // Navigate to a URL when history item is clicked | 162 | // Navigate to a URL when history item is clicked | ||
| 125 | const navigateTo = (url: string) => { | 163 | const navigateTo = (url: string) => { | ||
| 126 | window.location.href = url; | 164 | window.location.href = url; | ||
| @@ -130,8 +168,10 @@ | |||||
| 130 | const deleteHistoryEntry = async (id: number, event: Event) => { | 168 | const deleteHistoryEntry = async (id: number, event: Event) => { | ||
| 131 | event.stopPropagation(); // Prevent navigation | 169 | event.stopPropagation(); // Prevent navigation | ||
| 132 | 170 | ||||
| 133 | - | // Add to deleting set for animation | 171 | + | // Add to deleting array for animation |
| 134 | - | deletingIds = new Set([...deletingIds, id]); | 172 | + | if (!deletingIds.includes(id)) { |
| 173 | + | deletingIds = [...deletingIds, id]; | |||
| 174 | + | } | |||
| 135 | 175 | ||||
| 136 | try { | 176 | try { | ||
| 137 | const response = await fetch(`dumb://homepage/api/history/delete?id=${id}`); | 177 | const response = await fetch(`dumb://homepage/api/history/delete?id=${id}`); | ||
| @@ -139,23 +179,25 @@ | |||||
| 139 | // Wait a bit for the fade animation to complete | 179 | // Wait a bit for the fade animation to complete | ||
| 140 | await new Promise(resolve => setTimeout(resolve, 200)); | 180 | await new Promise(resolve => setTimeout(resolve, 200)); | ||
| 141 | 181 | ||||
| 142 | - | // Refresh history after successful deletion | 182 | + | // Reset pagination and refresh history after successful deletion |
| 183 | + | historyOffset = 0; | |||
| 184 | + | hasMoreHistory = true; | |||
| 143 | await fetchHistory(); | 185 | await fetchHistory(); | ||
| 144 | await fetchStats(); | 186 | await fetchStats(); | ||
| 145 | await fetchTopVisited(); | 187 | await fetchTopVisited(); | ||
| 146 | } else { | 188 | } else { | ||
| 147 | console.error('Failed to delete history entry:', response.status); | 189 | console.error('Failed to delete history entry:', response.status); | ||
| 148 | - | // Remove from deleting set if failed | 190 | + | // Remove from deleting array if failed |
| 149 | - | deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id)); | 191 | + | deletingIds = deletingIds.filter(i => i !== id); |
| 150 | } | 192 | } | ||
| 151 | } catch (error) { | 193 | } catch (error) { | ||
| 152 | console.error('Error deleting history entry:', error); | 194 | console.error('Error deleting history entry:', error); | ||
| 153 | - | // Remove from deleting set if failed | 195 | + | // Remove from deleting array if failed |
| 154 | - | deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id)); | 196 | + | deletingIds = deletingIds.filter(i => i !== id); |
| 155 | } | 197 | } | ||
| 156 | 198 | ||||
| 157 | - | // Remove from deleting set after refresh | 199 | + | // Remove from deleting array after refresh |
| 158 | - | deletingIds = new Set([...deletingIds].filter(deletingId => deletingId !== id)); | 200 | + | deletingIds = deletingIds.filter(i => i !== id); |
| 159 | }; | 201 | }; | ||
| 160 | 202 | ||||
| 161 | // Get domain from URL | 203 | // Get domain from URL | ||
| @@ -227,9 +269,36 @@ | |||||
| 227 | } | 269 | } | ||
| 228 | }; | 270 | }; | ||
| 229 | 271 | ||||
| 272 | + | // Intersection Observer for infinite scroll | |||
| 273 | + | let historyScrollSentinel = $state<HTMLElement | null>(null); | |||
| 274 | + | ||||
| 275 | + | const setupInfiniteScroll = () => { | |||
| 276 | + | if (!historyScrollSentinel) return; | |||
| 277 | + | ||||
| 278 | + | const observer = new IntersectionObserver( | |||
| 279 | + | (entries) => { | |||
| 280 | + | const entry = entries[0]; | |||
| 281 | + | if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) { | |||
| 282 | + | loadMoreHistory(); | |||
| 283 | + | } | |||
| 284 | + | }, | |||
| 285 | + | { | |||
| 286 | + | threshold: 0.1, | |||
| 287 | + | rootMargin: '50px' | |||
| 288 | + | } | |||
| 289 | + | ); | |||
| 290 | + | ||||
| 291 | + | observer.observe(historyScrollSentinel); | |||
| 292 | + | ||||
| 293 | + | return () => observer.disconnect(); | |||
| 294 | + | }; | |||
| 295 | + | ||||
| 230 | onMount(() => { | 296 | onMount(() => { | ||
| 231 | initializeShortcuts(); | 297 | initializeShortcuts(); | ||
| 232 | - | fetchHistory(); | 298 | + | fetchHistory().then(() => { |
| 299 | + | // Setup infinite scroll after initial history is loaded | |||
| 300 | + | setTimeout(setupInfiniteScroll, 100); | |||
| 301 | + | }); | |||
| 233 | fetchStats(); | 302 | fetchStats(); | ||
| 234 | fetchTopVisited(); | 303 | fetchTopVisited(); | ||
| 235 | }); | 304 | }); | ||
| @@ -252,7 +321,7 @@ | |||||
| 252 | {#each historyItems as item (item.url)} | 321 | {#each historyItems as item (item.url)} | ||
| 253 | <div | 322 | <div | ||
| 254 | class="history-item" | 323 | class="history-item" | ||
| 255 | - | class:deleting={deletingIds.has(item.id)} | 324 | + | class:deleting={isDeleting(item.id)} |
| 256 | role="button" | 325 | role="button" | ||
| 257 | tabindex="0" | 326 | tabindex="0" | ||
| 258 | onclick={() => navigateTo(item.url)} | 327 | onclick={() => navigateTo(item.url)} | ||
| @@ -309,6 +378,14 @@ | |||||
| 309 | </div> | 378 | </div> | ||
| 310 | </div> | 379 | </div> | ||
| 311 | {/each} | 380 | {/each} | ||
| 381 | + | ||||
| 382 | + | <!-- Infinite scroll sentinel and loading indicator --> | |||
| 383 | + | {#if hasMoreHistory} | |||
| 384 | + | <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div> | |||
| 385 | + | {#if loadingMoreHistory} | |||
| 386 | + | <div class="loading-more">Loading more history...</div> | |||
| 387 | + | {/if} | |||
| 388 | + | {/if} | |||
| 312 | {/if} | 389 | {/if} | ||
| 313 | </div> | 390 | </div> | ||
| 314 | </div> | 391 | </div> | ||
| @@ -725,6 +802,18 @@ | |||||
| 725 | color: #888; | 802 | color: #888; | ||
| 726 | } | 803 | } | ||
| 727 | 804 | ||||
| 805 | + | .scroll-sentinel { | |||
| 806 | + | height: 1px; | |||
| 807 | + | width: 100%; | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | .loading-more { | |||
| 811 | + | padding: 1rem; | |||
| 812 | + | text-align: center; | |||
| 813 | + | color: #888; | |||
| 814 | + | font-size: 0.9rem; | |||
| 815 | + | } | |||
| 816 | + | ||||
| 728 | .empty-state { | 817 | .empty-state { | ||
| 729 | padding: 3rem 2rem; | 818 | padding: 3rem 2rem; | ||
| 730 | text-align: center; | 819 | text-align: center; | ||
M internal/app/api/routes.go +34 -5
| @@ -58,19 +58,48 @@ func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) { | |||
| 58 | 58 | // HandleHistoryRecent handles GET /history/recent endpoint | |
| 59 | 59 | func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) { | |
| 60 | 60 | log.Printf("[api] GET /history/recent%s", u.RawQuery) | |
| 61 | - | // Parse limit | |
| 61 | + | // Parse limit and offset | |
| 62 | 62 | q := u.Query() | |
| 63 | - | limit := 50 | |
| 63 | + | limit := 20 | |
| 64 | 64 | if l := q.Get("limit"); l != "" { | |
| 65 | 65 | if n, err := strconv.Atoi(l); err == nil && n > 0 { | |
| 66 | 66 | limit = n | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| 69 | + | offset := 0 | |
| 70 | + | if o := q.Get("offset"); o != "" { | |
| 71 | + | if n, err := strconv.Atoi(o); err == nil && n >= 0 { | |
| 72 | + | offset = n | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 69 | 76 | ctx := context.Background() | |
| 70 | - | entries, err := h.browserService.GetRecentHistory(ctx, limit) | |
| 71 | - | if err != nil { | |
| 72 | - | return constants.ContentTypeJSON, []byte("[]"), true | |
| 77 | + | var entries []interface{} | |
| 78 | + | ||
| 79 | + | // Use paginated version if offset is provided | |
| 80 | + | if offset > 0 || q.Has("offset") { | |
| 81 | + | histEntries, err := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset) | |
| 82 | + | if err != nil { | |
| 83 | + | return constants.ContentTypeJSON, []byte("[]"), true | |
| 84 | + | } | |
| 85 | + | // Convert to interface slice for JSON marshaling | |
| 86 | + | entries = make([]interface{}, len(histEntries)) | |
| 87 | + | for i, entry := range histEntries { | |
| 88 | + | entries[i] = entry | |
| 89 | + | } | |
| 90 | + | } else { | |
| 91 | + | // Use original method for backward compatibility | |
| 92 | + | histEntries, err := h.browserService.GetRecentHistory(ctx, limit) | |
| 93 | + | if err != nil { | |
| 94 | + | return constants.ContentTypeJSON, []byte("[]"), true | |
| 95 | + | } | |
| 96 | + | // Convert to interface slice for JSON marshaling | |
| 97 | + | entries = make([]interface{}, len(histEntries)) | |
| 98 | + | for i, entry := range histEntries { | |
| 99 | + | entries[i] = entry | |
| 100 | + | } | |
| 73 | 101 | } | |
| 102 | + | ||
| 74 | 103 | b, _ := json.Marshal(entries) | |
| 75 | 104 | return constants.ContentTypeJSON, b, true | |
| 76 | 105 | } | |
| @@ -58,19 +58,48 @@ func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) { | |||||
| 58 | // HandleHistoryRecent handles GET /history/recent endpoint | 58 | // HandleHistoryRecent handles GET /history/recent endpoint | ||
| 59 | func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) { | 59 | func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) { | ||
| 60 | log.Printf("[api] GET /history/recent%s", u.RawQuery) | 60 | log.Printf("[api] GET /history/recent%s", u.RawQuery) | ||
| 61 | - | // Parse limit | 61 | + | // Parse limit and offset |
| 62 | q := u.Query() | 62 | q := u.Query() | ||
| 63 | - | limit := 50 | 63 | + | limit := 20 |
| 64 | if l := q.Get("limit"); l != "" { | 64 | if l := q.Get("limit"); l != "" { | ||
| 65 | if n, err := strconv.Atoi(l); err == nil && n > 0 { | 65 | if n, err := strconv.Atoi(l); err == nil && n > 0 { | ||
| 66 | limit = n | 66 | limit = n | ||
| 67 | } | 67 | } | ||
| 68 | } | 68 | } | ||
| 69 | + | offset := 0 | |||
| 70 | + | if o := q.Get("offset"); o != "" { | |||
| 71 | + | if n, err := strconv.Atoi(o); err == nil && n >= 0 { | |||
| 72 | + | offset = n | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 69 | ctx := context.Background() | 76 | ctx := context.Background() | ||
| 70 | - | entries, err := h.browserService.GetRecentHistory(ctx, limit) | 77 | + | var entries []interface{} |
| 71 | - | if err != nil { | 78 | + | |
| 72 | - | return constants.ContentTypeJSON, []byte("[]"), true | 79 | + | // Use paginated version if offset is provided |
| 80 | + | if offset > 0 || q.Has("offset") { | |||
| 81 | + | histEntries, err := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return constants.ContentTypeJSON, []byte("[]"), true | |||
| 84 | + | } | |||
| 85 | + | // Convert to interface slice for JSON marshaling | |||
| 86 | + | entries = make([]interface{}, len(histEntries)) | |||
| 87 | + | for i, entry := range histEntries { | |||
| 88 | + | entries[i] = entry | |||
| 89 | + | } | |||
| 90 | + | } else { | |||
| 91 | + | // Use original method for backward compatibility | |||
| 92 | + | histEntries, err := h.browserService.GetRecentHistory(ctx, limit) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return constants.ContentTypeJSON, []byte("[]"), true | |||
| 95 | + | } | |||
| 96 | + | // Convert to interface slice for JSON marshaling | |||
| 97 | + | entries = make([]interface{}, len(histEntries)) | |||
| 98 | + | for i, entry := range histEntries { | |||
| 99 | + | entries[i] = entry | |||
| 100 | + | } | |||
| 73 | } | 101 | } | ||
| 102 | + | ||||
| 74 | b, _ := json.Marshal(entries) | 103 | b, _ := json.Marshal(entries) | ||
| 75 | return constants.ContentTypeJSON, b, true | 104 | return constants.ContentTypeJSON, b, true | ||
| 76 | } | 105 | } | ||
M internal/db/history.sql.go +38 -0
| @@ -73,6 +73,44 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error | |||
| 73 | 73 | return items, nil | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many | |
| 77 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |
| 78 | + | FROM history | |
| 79 | + | ORDER BY last_visited DESC | |
| 80 | + | LIMIT ? OFFSET ? | |
| 81 | + | ` | |
| 82 | + | ||
| 83 | + | func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) { | |
| 84 | + | rows, err := q.db.QueryContext(ctx, GetHistoryWithOffset, limit, offset) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | defer rows.Close() | |
| 89 | + | items := []History{} | |
| 90 | + | for rows.Next() { | |
| 91 | + | var i History | |
| 92 | + | if err := rows.Scan( | |
| 93 | + | &i.ID, | |
| 94 | + | &i.Url, | |
| 95 | + | &i.Title, | |
| 96 | + | &i.VisitCount, | |
| 97 | + | &i.LastVisited, | |
| 98 | + | &i.CreatedAt, | |
| 99 | + | &i.FaviconUrl, | |
| 100 | + | ); err != nil { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | items = append(items, i) | |
| 104 | + | } | |
| 105 | + | if err := rows.Close(); err != nil { | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | if err := rows.Err(); err != nil { | |
| 109 | + | return nil, err | |
| 110 | + | } | |
| 111 | + | return items, nil | |
| 112 | + | } | |
| 113 | + | ||
| 76 | 114 | const GetShortcuts = `-- name: GetShortcuts :many | |
| 77 | 115 | SELECT id, shortcut, url_template, description, created_at | |
| 78 | 116 | FROM shortcuts | |
| @@ -73,6 +73,44 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error | |||||
| 73 | return items, nil | 73 | return items, nil | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many | |||
| 77 | + | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |||
| 78 | + | FROM history | |||
| 79 | + | ORDER BY last_visited DESC | |||
| 80 | + | LIMIT ? OFFSET ? | |||
| 81 | + | ` | |||
| 82 | + | ||||
| 83 | + | func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) { | |||
| 84 | + | rows, err := q.db.QueryContext(ctx, GetHistoryWithOffset, limit, offset) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | defer rows.Close() | |||
| 89 | + | items := []History{} | |||
| 90 | + | for rows.Next() { | |||
| 91 | + | var i History | |||
| 92 | + | if err := rows.Scan( | |||
| 93 | + | &i.ID, | |||
| 94 | + | &i.Url, | |||
| 95 | + | &i.Title, | |||
| 96 | + | &i.VisitCount, | |||
| 97 | + | &i.LastVisited, | |||
| 98 | + | &i.CreatedAt, | |||
| 99 | + | &i.FaviconUrl, | |||
| 100 | + | ); err != nil { | |||
| 101 | + | return nil, err | |||
| 102 | + | } | |||
| 103 | + | items = append(items, i) | |||
| 104 | + | } | |||
| 105 | + | if err := rows.Close(); err != nil { | |||
| 106 | + | return nil, err | |||
| 107 | + | } | |||
| 108 | + | if err := rows.Err(); err != nil { | |||
| 109 | + | return nil, err | |||
| 110 | + | } | |||
| 111 | + | return items, nil | |||
| 112 | + | } | |||
| 113 | + | ||||
| 76 | const GetShortcuts = `-- name: GetShortcuts :many | 114 | const GetShortcuts = `-- name: GetShortcuts :many | ||
| 77 | SELECT id, shortcut, url_template, description, created_at | 115 | SELECT id, shortcut, url_template, description, created_at | ||
| 78 | FROM shortcuts | 116 | FROM shortcuts | ||
M internal/db/interfaces.go +1 -0
| @@ -17,6 +17,7 @@ type ZoomQuerier interface { | |||
| 17 | 17 | // HistoryQuerier defines the interface for history-related database operations | |
| 18 | 18 | type HistoryQuerier interface { | |
| 19 | 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 20 | + | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| 20 | 21 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |
| 21 | 22 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |
| 22 | 23 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |
| @@ -17,6 +17,7 @@ type ZoomQuerier interface { | |||||
| 17 | // HistoryQuerier defines the interface for history-related database operations | 17 | // HistoryQuerier defines the interface for history-related database operations | ||
| 18 | type HistoryQuerier interface { | 18 | type HistoryQuerier interface { | ||
| 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 20 | + | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |||
| 20 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | 21 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | ||
| 21 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | 22 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | ||
| 22 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | 23 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | ||
M internal/db/querier.go +1 -0
| @@ -20,6 +20,7 @@ type Querier interface { | |||
| 20 | 20 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 21 | 21 | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | |
| 22 | 22 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 23 | + | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| 23 | 24 | GetShortcuts(ctx context.Context) ([]Shortcut, error) | |
| 24 | 25 | // Get zoom level for a specific domain | |
| 25 | 26 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | |
| @@ -20,6 +20,7 @@ type Querier interface { | |||||
| 20 | DeleteZoomLevel(ctx context.Context, domain string) error | 20 | DeleteZoomLevel(ctx context.Context, domain string) error | ||
| 21 | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | 21 | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | ||
| 22 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 22 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 23 | + | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |||
| 23 | GetShortcuts(ctx context.Context) ([]Shortcut, error) | 24 | GetShortcuts(ctx context.Context) ([]Shortcut, error) | ||
| 24 | // Get zoom level for a specific domain | 25 | // Get zoom level for a specific domain | ||
| 25 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | 26 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | ||
M internal/db/queries/history.sql +6 -0
| @@ -4,6 +4,12 @@ FROM history | |||
| 4 | 4 | ORDER BY last_visited DESC | |
| 5 | 5 | LIMIT ?; | |
| 6 | 6 | ||
| 7 | + | -- name: GetHistoryWithOffset :many | |
| 8 | + | SELECT * | |
| 9 | + | FROM history | |
| 10 | + | ORDER BY last_visited DESC | |
| 11 | + | LIMIT ? OFFSET ?; | |
| 12 | + | ||
| 7 | 13 | -- name: AddOrUpdateHistory :exec | |
| 8 | 14 | INSERT INTO history (url, title) | |
| 9 | 15 | VALUES (?, ?) | |
| @@ -4,6 +4,12 @@ FROM history | |||||
| 4 | ORDER BY last_visited DESC | 4 | ORDER BY last_visited DESC | ||
| 5 | LIMIT ?; | 5 | LIMIT ?; | ||
| 6 | 6 | ||||
| 7 | + | -- name: GetHistoryWithOffset :many | |||
| 8 | + | SELECT * | |||
| 9 | + | FROM history | |||
| 10 | + | ORDER BY last_visited DESC | |||
| 11 | + | LIMIT ? OFFSET ?; | |||
| 12 | + | ||||
| 7 | -- name: AddOrUpdateHistory :exec | 13 | -- name: AddOrUpdateHistory :exec | ||
| 8 | INSERT INTO history (url, title) | 14 | INSERT INTO history (url, title) | ||
| 9 | VALUES (?, ?) | 15 | VALUES (?, ?) | ||
M services/browser_service.go +32 -0
| @@ -250,6 +250,38 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His | |||
| 250 | 250 | return result, nil | |
| 251 | 251 | } | |
| 252 | 252 | ||
| 253 | + | // GetRecentHistoryWithOffset returns recent browser history entries with pagination support. | |
| 254 | + | func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) { | |
| 255 | + | if limit <= 0 { | |
| 256 | + | limit = 20 // Default limit for pagination | |
| 257 | + | } | |
| 258 | + | if offset < 0 { | |
| 259 | + | offset = 0 | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset)) | |
| 263 | + | if err != nil { | |
| 264 | + | return nil, err | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | result := make([]HistoryEntry, len(entries)) | |
| 268 | + | for i, entry := range entries { | |
| 269 | + | // Defensive cast to int32 to prevent overflow | |
| 270 | + | vc := clampToInt32(entry.VisitCount.Int64) | |
| 271 | + | result[i] = HistoryEntry{ | |
| 272 | + | ID: entry.ID, | |
| 273 | + | URL: entry.Url, | |
| 274 | + | Title: entry.Title.String, | |
| 275 | + | FaviconURL: entry.FaviconUrl.String, | |
| 276 | + | VisitCount: vc, | |
| 277 | + | LastVisited: entry.LastVisited.Time, | |
| 278 | + | CreatedAt: entry.CreatedAt.Time, | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | return result, nil | |
| 283 | + | } | |
| 284 | + | ||
| 253 | 285 | // SearchHistory searches browser history. | |
| 254 | 286 | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | |
| 255 | 287 | if query == "" { | |
| @@ -250,6 +250,38 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His | |||||
| 250 | return result, nil | 250 | return result, nil | ||
| 251 | } | 251 | } | ||
| 252 | 252 | ||||
| 253 | + | // GetRecentHistoryWithOffset returns recent browser history entries with pagination support. | |||
| 254 | + | func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) { | |||
| 255 | + | if limit <= 0 { | |||
| 256 | + | limit = 20 // Default limit for pagination | |||
| 257 | + | } | |||
| 258 | + | if offset < 0 { | |||
| 259 | + | offset = 0 | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset)) | |||
| 263 | + | if err != nil { | |||
| 264 | + | return nil, err | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | result := make([]HistoryEntry, len(entries)) | |||
| 268 | + | for i, entry := range entries { | |||
| 269 | + | // Defensive cast to int32 to prevent overflow | |||
| 270 | + | vc := clampToInt32(entry.VisitCount.Int64) | |||
| 271 | + | result[i] = HistoryEntry{ | |||
| 272 | + | ID: entry.ID, | |||
| 273 | + | URL: entry.Url, | |||
| 274 | + | Title: entry.Title.String, | |||
| 275 | + | FaviconURL: entry.FaviconUrl.String, | |||
| 276 | + | VisitCount: vc, | |||
| 277 | + | LastVisited: entry.LastVisited.Time, | |||
| 278 | + | CreatedAt: entry.CreatedAt.Time, | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | return result, nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 253 | // SearchHistory searches browser history. | 285 | // SearchHistory searches browser history. | ||
| 254 | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | 286 | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | ||
| 255 | if query == "" { | 287 | if query == "" { | ||