search and/or read your saved and liked bluesky posts
wails go svelte sqlite desktop bluesky
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: empty state UI

* toast notifications

* keyboard shortcuts

* Svelte transitions for nicer UX

+261 -35
+4 -4
TODO.md
··· 61 61 62 62 ## Milestone 7 — Polish 63 63 64 - - [ ] Animations with svelte APIs (transition, animate) 65 - - [ ] Empty state: show "No posts indexed" with prompt to refresh 66 - - [ ] Error handling: toast/notification for network failures, auth expiry 67 - - [ ] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer 64 + - [x] Animations with svelte APIs (transition, animate) 65 + - [x] Empty state: show "No posts indexed" with prompt to refresh 66 + - [x] Error handling: toast/notification for network failures, auth expiry 67 + - [x] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer 68 68 - [ ] Window title and app icon (`build/appicon.png`) 69 69 - [ ] Production build verification (`wails build` → macOS `.app` bundle) 70 70 - [ ] README with build instructions, screenshots, and usage
+104 -31
frontend/src/App.svelte
··· 3 3 import "@fontsource-variable/geist"; 4 4 import "@fontsource-variable/lora"; 5 5 import { onMount } from "svelte"; 6 + import { fade, slide } from "svelte/transition"; 6 7 import { Login, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; 7 8 import { Refresh, IsIndexing } from "../wailsjs/go/main/IndexService"; 8 9 import { Search, CountPosts } from "../wailsjs/go/main/SearchService"; ··· 10 11 import SearchBar from "./lib/components/SearchBar.svelte"; 11 12 import DataTable from "./lib/components/DataTable.svelte"; 12 13 import LogViewer from "./lib/components/LogViewer.svelte"; 14 + import Toaster from "./lib/components/Toast.svelte"; 15 + import { toaster } from "./lib/stores/toast.svelte"; 16 + import EmptyState from "./lib/components/EmptyState.svelte"; 13 17 import type { main } from "../wailsjs/go/models"; 14 18 15 19 type AuthInfo = { handle: string; did: string }; ··· 34 38 let isSearching = $state(false); 35 39 let showLogs = $state(false); 36 40 37 - onMount(async () => { 38 - await checkAuthStatus(); 41 + onMount(() => { 42 + document.addEventListener("keydown", handleGlobalKeydown); 39 43 40 - EventsOn("index:started", () => { 41 - isIndexing = true; 42 - showProgress = true; 43 - indexStats = { fetched: 0, inserted: 0, errors: 0, total: 0 }; 44 - }); 44 + checkAuthStatus().then(() => { 45 + EventsOn("index:started", () => { 46 + isIndexing = true; 47 + showProgress = true; 48 + indexStats = { fetched: 0, inserted: 0, errors: 0, total: 0 }; 49 + }); 45 50 46 - EventsOn("index:progress", (stats: any) => { 47 - indexStats = stats; 48 - }); 51 + EventsOn("index:progress", (stats: any) => { 52 + indexStats = stats; 53 + }); 49 54 50 - EventsOn("index:done", (result: any) => { 51 - isIndexing = false; 52 - indexStats.total = result.total || 0; 55 + EventsOn("index:done", (result: any) => { 56 + isIndexing = false; 57 + indexStats.total = result.total || 0; 58 + loadPosts(); 59 + toaster.success(`Indexed ${result.total} posts successfully`); 60 + setTimeout(() => { 61 + showProgress = false; 62 + }, 3000); 63 + }); 64 + 65 + IsIndexing().then((indexing) => { 66 + isIndexing = indexing; 67 + if (isIndexing) { 68 + showProgress = true; 69 + } 70 + }); 71 + 53 72 loadPosts(); 54 - setTimeout(() => { 55 - showProgress = false; 56 - }, 3000); 57 73 }); 58 74 59 - isIndexing = await IsIndexing(); 60 - if (isIndexing) { 61 - showProgress = true; 62 - } 63 - 64 - await loadPosts(); 75 + return () => { 76 + document.removeEventListener("keydown", handleGlobalKeydown); 77 + }; 65 78 }); 66 79 67 80 async function checkAuthStatus() { ··· 78 91 } 79 92 } catch (err) { 80 93 status = "Failed to check authentication status"; 94 + toaster.error("Failed to check authentication status"); 81 95 } 82 96 } 83 97 84 98 async function handleLogin() { 85 99 if (!handle.trim()) { 86 100 status = "Please enter your Bluesky handle"; 101 + toaster.warning("Please enter your Bluesky handle"); 87 102 return; 88 103 } 89 104 ··· 93 108 try { 94 109 await Login(handle.trim()); 95 110 status = "Login successful!"; 111 + toaster.success("Login successful!"); 96 112 await checkAuthStatus(); 97 113 } catch (err) { 98 114 status = `Login failed: ${err}`; 115 + toaster.error(`Login failed: ${err}`); 99 116 } finally { 100 117 isLoading = false; 101 118 } ··· 108 125 await Refresh(refreshLimit); 109 126 } catch (err) { 110 127 status = `Refresh failed: ${err}`; 128 + toaster.error(`Refresh failed: ${err}`); 111 129 } 112 130 } 113 131 ··· 117 135 await performSearch(searchQuery, searchSource); 118 136 } catch (err) { 119 137 console.error("Failed to load posts:", err); 138 + toaster.error("Failed to load posts"); 120 139 } 121 140 } 122 141 ··· 128 147 } catch (err) { 129 148 console.error("Search failed:", err); 130 149 searchResults = []; 150 + toaster.error("Search failed"); 131 151 } finally { 132 152 isSearching = false; 133 153 } ··· 173 193 handleLogin(); 174 194 } 175 195 } 196 + 197 + function handleGlobalKeydown(event: KeyboardEvent) { 198 + if ((event.metaKey || event.ctrlKey) && event.key === "k") { 199 + event.preventDefault(); 200 + const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement; 201 + if (searchInput) { 202 + searchInput.focus(); 203 + } 204 + } 205 + 206 + if ((event.metaKey || event.ctrlKey) && event.key === "r") { 207 + event.preventDefault(); 208 + if (!isIndexing) { 209 + handleRefresh(); 210 + } 211 + } 212 + 213 + if ((event.metaKey || event.ctrlKey) && event.key === "l") { 214 + event.preventDefault(); 215 + showLogs = !showLogs; 216 + } 217 + } 176 218 </script> 219 + 220 + <Toaster /> 177 221 178 222 <main class="min-h-screen bg-black text-bright flex flex-col"> 179 223 {#if !isLoggedIn} 180 224 <!-- Login View --> 181 - <div class="flex-1 flex items-center justify-center p-4"> 225 + <div class="flex-1 flex items-center justify-center p-4" transition:fade={{ duration: 300 }}> 182 226 <div class="w-full max-w-md"> 183 227 <div class="text-center mb-8"> 184 228 <h1 class="font-serif text-4xl mb-2">bsky-browser</h1> ··· 212 256 </div> 213 257 214 258 {#if status} 215 - <div class="mt-4 p-3 bg-black border border-outline rounded"> 259 + <div class="mt-4 p-3 bg-black border border-outline rounded" transition:slide={{ duration: 200 }}> 216 260 <p class="font-mono text-xs text-muted">{status}</p> 217 261 </div> 218 262 {/if} ··· 232 276 233 277 <div class="flex items-center gap-3"> 234 278 <button 235 - onclick={() => showLogs = !showLogs} 236 - class="font-mono text-xs px-3 py-2 rounded bg-surface border border-outline hover:bg-outline text-bright transition-colors {showLogs ? 'bg-[#333]' : ''}"> 237 - {showLogs ? 'Hide Logs' : 'Show Logs'} 279 + onclick={() => (showLogs = !showLogs)} 280 + class="font-mono text-xs px-3 py-2 rounded bg-surface border border-outline hover:bg-outline text-bright transition-colors {showLogs 281 + ? 'bg-[#333]' 282 + : ''}"> 283 + {#if showLogs} 284 + <span class="flex items-center gap-2"> 285 + <i class="i-ri-eye-off-line"></i> 286 + <span>Hide Logs</span> 287 + </span> 288 + {:else} 289 + <span class="flex items-center gap-2"> 290 + <i class="i-ri-eye-line"></i> 291 + <span>Show Logs</span> 292 + </span> 293 + {/if} 238 294 </button> 239 295 240 296 <div class="flex items-center gap-2"> ··· 255 311 {#if isIndexing} 256 312 <span class="animate-pulse">Refreshing...</span> 257 313 {:else} 258 - Refresh 314 + <span class="flex items-center gap-2"> 315 + <i class="i-ri-refresh-line"></i> 316 + <span>Refresh</span> 317 + </span> 259 318 {/if} 260 319 </button> 261 320 </div> ··· 273 332 <div class="flex items-center justify-center h-full"> 274 333 <span class="font-sans text-muted animate-pulse">Searching...</span> 275 334 </div> 335 + {:else if totalPosts === 0} 336 + <EmptyState onRefresh={handleRefresh} /> 276 337 {:else} 277 338 <DataTable posts={searchResults} {sortColumn} {sortDirection} onSort={handleSort} /> 278 339 {/if} 279 340 </div> 280 341 281 342 <!-- Log Viewer --> 282 - <LogViewer visible={showLogs} /> 343 + {#if showLogs} 344 + <div transition:slide={{ duration: 300 }}> 345 + <LogViewer visible={showLogs} /> 346 + </div> 347 + {/if} 283 348 284 349 <!-- Progress Bar (bottom pinned) --> 285 350 {#if showProgress} 286 - <div class="border-t border-outline bg-surface px-6 py-3"> 351 + <div class="border-t border-outline bg-surface px-6 py-3" transition:slide={{ duration: 300 }}> 287 352 <div class="flex items-center justify-between mb-2"> 288 353 <span class="font-sans text-sm text-muted"> 289 - {isIndexing ? "Indexing..." : "Indexing complete"} 354 + <!-- {isIndexing ? "Indexing..." : "Indexing complete"} --> 355 + {#if isIndexing} 356 + <span class="animate-pulse">Indexing...</span> 357 + {:else} 358 + <span class="flex items-center gap-2"> 359 + <i class="i-ri-check-line text-emerald-400"></i> 360 + <span>Indexing complete</span> 361 + </span> 362 + {/if} 290 363 </span> 291 364 <span class="font-mono text-xs text-muted"> 292 365 {indexStats.inserted} inserted / {indexStats.fetched} fetched
+59
frontend/src/lib/components/EmptyState.svelte
··· 1 + <script lang="ts"> 2 + import { fade } from "svelte/transition"; 3 + 4 + interface Props { 5 + onRefresh: () => void; 6 + } 7 + 8 + let { onRefresh }: Props = $props(); 9 + </script> 10 + 11 + <div 12 + transition:fade={{ duration: 300 }} 13 + class="flex flex-col items-center justify-center h-full text-center p-8" 14 + > 15 + <div class="mb-6"> 16 + <!-- Empty state icon - inbox/archive symbol --> 17 + <svg 18 + class="w-16 h-16 text-muted mx-auto mb-4" 19 + fill="none" 20 + stroke="currentColor" 21 + viewBox="0 0 24 24" 22 + > 23 + <path 24 + stroke-linecap="round" 25 + stroke-linejoin="round" 26 + stroke-width="1.5" 27 + d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" 28 + /> 29 + </svg> 30 + </div> 31 + 32 + <h2 class="font-serif text-2xl text-bright mb-2">No posts indexed</h2> 33 + 34 + <p class="font-sans text-muted mb-6 max-w-md"> 35 + Your Bluesky bookmarks and likes haven't been indexed yet. 36 + Click the button below to start indexing your posts. 37 + </p> 38 + 39 + <button 40 + onclick={onRefresh} 41 + class="bg-surface border border-outline hover:bg-outline text-bright font-sans py-3 px-6 rounded-lg transition-colors flex items-center gap-2" 42 + > 43 + <!-- Refresh icon --> 44 + <svg 45 + class="w-5 h-5" 46 + fill="none" 47 + stroke="currentColor" 48 + viewBox="0 0 24 24" 49 + > 50 + <path 51 + stroke-linecap="round" 52 + stroke-linejoin="round" 53 + stroke-width="2" 54 + d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" 55 + /> 56 + </svg> 57 + Refresh Data 58 + </button> 59 + </div>
+53
frontend/src/lib/components/Toast.svelte
··· 1 + <script lang="ts"> 2 + import { fade, fly } from "svelte/transition"; 3 + import { flip } from "svelte/animate"; 4 + import { toaster, type ToastKind } from "../stores/toast.svelte"; 5 + 6 + function getTypeStyle(kind: ToastKind) { 7 + switch (kind) { 8 + case "info": 9 + return "bg-blue-500/20 border-blue-500/50 text-blue-400"; 10 + case "success": 11 + return "bg-green-500/20 border-green-500/50 text-green-400"; 12 + case "warning": 13 + return "bg-yellow-500/20 border-yellow-500/50 text-yellow-400"; 14 + case "error": 15 + return "bg-red-500/20 border-red-500/50 text-red-400"; 16 + } 17 + } 18 + </script> 19 + 20 + {#snippet typeIcon(kind: ToastKind)} 21 + {#if kind === "info"} 22 + <i class="i-ri-info-i"></i> 23 + {:else if kind === "success"} 24 + <i class="i-ri-check-line"></i> 25 + {:else if kind === "warning"} 26 + <i class="i-ri-error-warning-line"></i> 27 + {:else if kind === "error"} 28 + <i class="i-ri-error-warning-fill"></i> 29 + {/if} 30 + {/snippet} 31 + 32 + <div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"> 33 + {#each toaster.toasts as toast (toast.id)} 34 + <div 35 + in:fly={{ x: 100, duration: 300 }} 36 + out:fade={{ duration: 200 }} 37 + animate:flip={{ duration: 200 }} 38 + class="pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm min-w-[300px] max-w-[400px] {getTypeStyle( 39 + toast.kind, 40 + )}"> 41 + <span class="font-sans text-lg flex items-center"> 42 + {@render typeIcon(toast.kind)} 43 + </span> 44 + <p class="font-sans text-sm flex-1">{toast.message}</p> 45 + <button 46 + onclick={() => toaster.remove(toast.id)} 47 + class="font-mono text-lg opacity-50 hover:opacity-100 transition-opacity flex items-center"> 48 + <span class="sr-only">Dismiss {toast.kind} toast ({toast.id})</span> 49 + <i class="i-ri-close-line"></i> 50 + </button> 51 + </div> 52 + {/each} 53 + </div>
+41
frontend/src/lib/stores/toast.svelte.ts
··· 1 + export type ToastKind = "info" | "success" | "warning" | "error"; 2 + 3 + export type Toast = { id: number; message: string; kind: ToastKind }; 4 + 5 + class ToastStore { 6 + toasts = $state<Toast[]>([]); 7 + private id = 0; 8 + 9 + add(message: string, kind: ToastKind, duration = 5000) { 10 + const toastId = ++this.id; 11 + this.toasts = [...this.toasts, { id: toastId, message, kind }]; 12 + 13 + setTimeout(() => { 14 + this.remove(toastId); 15 + }, duration); 16 + 17 + return toastId; 18 + } 19 + 20 + remove(id: number) { 21 + this.toasts = this.toasts.filter((t) => t.id !== id); 22 + } 23 + 24 + info(message: string, duration?: number) { 25 + return this.add(message, "info", duration); 26 + } 27 + 28 + success(message: string, duration?: number) { 29 + return this.add(message, "success", duration); 30 + } 31 + 32 + warning(message: string, duration?: number) { 33 + return this.add(message, "warning", duration); 34 + } 35 + 36 + error(message: string, duration?: number) { 37 + return this.add(message, "error", duration || 8000); 38 + } 39 + } 40 + 41 + export const toaster = new ToastStore();