diff --git a/TODO.md b/TODO.md index 29cb239..85bd198 100644 --- a/TODO.md +++ b/TODO.md @@ -39,14 +39,14 @@ ## Milestone 5 โ€” Search & Data Table -- [ ] Implement `SearchService` struct with Wails service binding -- [ ] `Search(query, source)` โ€” FTS5 query with BM25 ranking and source filter -- [ ] `CountPosts()` โ€” total indexed post count -- [ ] Frontend: search bar with query input and source filter (All / Saved / Liked segmented control) -- [ ] Frontend: tabbed data table component (Saved / Liked / All tabs) -- [ ] Columns: Author Handle, Text (truncated), Created At, โ™ฅ Likes, ๐Ÿ” Reposts, ๐Ÿ’ฌ Replies, Source -- [ ] Client-side column sorting (click header to toggle asc/desc) -- [ ] Row click โ†’ open post URL in default browser via `runtime.BrowserOpenURL` +- [x] Implement `SearchService` struct with Wails service binding +- [x] `Search(query, source)` โ€” FTS5 query with BM25 ranking and source filter +- [x] `CountPosts()` โ€” total indexed post count +- [x] Frontend: search bar with query input and source filter (All / Saved / Liked segmented control) +- [x] Frontend: tabbed data table component (Saved / Liked / All tabs) +- [x] Columns: Author Handle, Text (truncated), Created At, โ™ฅ Likes, ๐Ÿ” Reposts, ๐Ÿ’ฌ Replies, Source +- [x] Client-side column sorting (click header to toggle asc/desc) +- [x] Row click โ†’ open post URL in default browser via `runtime.BrowserOpenURL` ## Milestone 6 โ€” Facets & Log Viewer @@ -61,6 +61,7 @@ ## Milestone 7 โ€” Polish +- [ ] Animations with svelte APIs (transition, animate) - [ ] Empty state: show "No posts indexed" with prompt to refresh - [ ] Error handling: toast/notification for network failures, auth expiry - [ ] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer diff --git a/app.go b/app.go index 12add69..7025faf 100644 --- a/app.go +++ b/app.go @@ -2,23 +2,26 @@ package main import ( "context" - "fmt" "os" "path/filepath" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct type App struct { - ctx context.Context - authService *AuthService - indexService *IndexService + ctx context.Context + authService *AuthService + indexService *IndexService + searchService *SearchService } // NewApp creates a new App application struct func NewApp() *App { return &App{ - authService: NewAuthService(), - indexService: NewIndexService(), + authService: NewAuthService(), + indexService: NewIndexService(), + searchService: NewSearchService(), } } @@ -31,13 +34,13 @@ func (a *App) startup(ctx context.Context) { dbPath := getDBPath() if err := Open(dbPath); err != nil { - fmt.Printf("failed to open database: %v\n", err) + runtime.LogErrorf(a.ctx, "failed to open database: %v", err) return } if a.authService.IsAuthenticated() { if err := a.authService.RefreshSession(); err != nil { - fmt.Printf("token refresh failed on startup: %v\n", err) + runtime.LogWarningf(a.ctx, "token refresh failed on startup: %v", err) } } } @@ -45,7 +48,7 @@ func (a *App) startup(ctx context.Context) { // shutdown is called when the app shuts down func (a *App) shutdown(ctx context.Context) { if err := Close(); err != nil { - fmt.Printf("failed to close database: %v\n", err) + runtime.LogErrorf(ctx, "failed to close database: %v", err) } } @@ -63,8 +66,3 @@ func getDBPath() string { return filepath.Join(configDir, "bsky-browser", "bsky-browser.db") } - -// Greet returns a greeting for the given name -func (a *App) Greet(name string) string { - return fmt.Sprintf("Hello %s, It's show time!", name) -} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 2d1cf71..42492fa 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -594a3cbf221ff3dc93082fe39bc9c084 \ No newline at end of file +02bc6d89a1b36e6363147a6cafdca3d7 \ No newline at end of file diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b71be77..45b0e11 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,9 +5,14 @@ import { onMount } from "svelte"; import { Login, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; import { Refresh, IsIndexing } from "../wailsjs/go/main/IndexService"; + import { Search, CountPosts } from "../wailsjs/go/main/SearchService"; import { EventsOn } from "../wailsjs/runtime/runtime"; + import SearchBar from "./lib/components/SearchBar.svelte"; + import DataTable from "./lib/components/DataTable.svelte"; + import type { main } from "../wailsjs/go/models"; type AuthInfo = { handle: string; did: string }; + type IndexStats = { fetched: number; inserted: number; errors: number; total: number }; let handle = $state(""); @@ -19,6 +24,13 @@ let refreshLimit = $state(0); let indexStats = $state({ fetched: 0, inserted: 0, errors: 0, total: 0 }); let showProgress = $state(false); + let searchQuery = $state(""); + let searchSource = $state(""); + let searchResults = $state([]); + let totalPosts = $state(0); + let sortColumn = $state("created_at"); + let sortDirection = $state<"asc" | "desc">("desc"); + let isSearching = $state(false); onMount(async () => { await checkAuthStatus(); @@ -36,6 +48,7 @@ EventsOn("index:done", (result: any) => { isIndexing = false; indexStats.total = result.total || 0; + loadPosts(); setTimeout(() => { showProgress = false; }, 3000); @@ -45,6 +58,8 @@ if (isIndexing) { showProgress = true; } + + await loadPosts(); }); async function checkAuthStatus() { @@ -94,6 +109,67 @@ } } + async function loadPosts() { + try { + totalPosts = await CountPosts(); + await performSearch(searchQuery, searchSource); + } catch (err) { + console.error("Failed to load posts:", err); + } + } + + async function performSearch(query: string, source: string) { + if (!query.trim()) { + query = "*"; + } + + isSearching = true; + try { + const results = await Search(query, source); + searchResults = sortResults(results); + } catch (err) { + console.error("Search failed:", err); + searchResults = []; + } finally { + isSearching = false; + } + } + + function sortResults(results: main.SearchResult[]): main.SearchResult[] { + return [...results].sort((a, b) => { + let aVal: any = a[sortColumn as keyof main.SearchResult]; + let bVal: any = b[sortColumn as keyof main.SearchResult]; + + if (sortColumn === "created_at" || sortColumn === "indexed_at") { + aVal = aVal ? new Date(aVal).getTime() : 0; + bVal = bVal ? new Date(bVal).getTime() : 0; + } + + if (typeof aVal === "number" && typeof bVal === "number") { + return sortDirection === "asc" ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal || "").toLowerCase(); + const bStr = String(bVal || "").toLowerCase(); + + if (sortDirection === "asc") { + return aStr < bStr ? -1 : aStr > bStr ? 1 : 0; + } else { + return aStr > bStr ? -1 : aStr < bStr ? 1 : 0; + } + }); + } + + function handleSort(column: string) { + if (sortColumn === column) { + sortDirection = sortDirection === "asc" ? "desc" : "asc"; + } else { + sortColumn = column; + sortDirection = "desc"; + } + searchResults = sortResults(searchResults); + } + function handleKeydown(event: KeyboardEvent) { if (event.key === "Enter" && !isLoading) { handleLogin(); @@ -101,7 +177,7 @@ } -
+
{#if !isLoggedIn}
@@ -122,13 +198,13 @@ bind:value={handle} onkeydown={handleKeydown} disabled={isLoading} - class="w-full bg-black border border-outline rounded px-4 py-2 font-mono text-sm text-[#e5e5e5] placeholder-[#333] focus:outline-none focus:border-[#333] disabled:opacity-50" /> + class="w-full bg-black border border-outline rounded px-4 py-2 font-mono text-sm text-bright placeholder-[#333] focus:outline-none focus:border-[#333] disabled:opacity-50" />
+ {/each} + + + + diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts deleted file mode 100755 index 02a3bb9..0000000 --- a/frontend/wailsjs/go/main/App.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL -// This file is automatically generated. DO NOT EDIT - -export function Greet(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js deleted file mode 100755 index c71ae77..0000000 --- a/frontend/wailsjs/go/main/App.js +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL -// This file is automatically generated. DO NOT EDIT - -export function Greet(arg1) { - return window['go']['main']['App']['Greet'](arg1); -} diff --git a/frontend/wailsjs/go/main/SearchService.d.ts b/frontend/wailsjs/go/main/SearchService.d.ts new file mode 100755 index 0000000..627ab6e --- /dev/null +++ b/frontend/wailsjs/go/main/SearchService.d.ts @@ -0,0 +1,7 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL +// This file is automatically generated. DO NOT EDIT +import {main} from '../models'; + +export function CountPosts():Promise; + +export function Search(arg1:string,arg2:string):Promise>; diff --git a/frontend/wailsjs/go/main/SearchService.js b/frontend/wailsjs/go/main/SearchService.js new file mode 100755 index 0000000..aac86e4 --- /dev/null +++ b/frontend/wailsjs/go/main/SearchService.js @@ -0,0 +1,11 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL +// This file is automatically generated. DO NOT EDIT + +export function CountPosts() { + return window['go']['main']['SearchService']['CountPosts'](); +} + +export function Search(arg1, arg2) { + return window['go']['main']['SearchService']['Search'](arg1, arg2); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 58dfb2a..3c065be 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -55,6 +55,62 @@ export namespace main { return a; } } + export class SearchResult { + uri: string; + cid: string; + author_did: string; + author_handle: string; + text: string; + // Go type: time + created_at: any; + like_count: number; + repost_count: number; + reply_count: number; + source: string; + facets: string; + // Go type: time + indexed_at: any; + rank: number; + + static createFrom(source: any = {}) { + return new SearchResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.uri = source["uri"]; + this.cid = source["cid"]; + this.author_did = source["author_did"]; + this.author_handle = source["author_handle"]; + this.text = source["text"]; + this.created_at = this.convertValues(source["created_at"], null); + this.like_count = source["like_count"]; + this.repost_count = source["repost_count"]; + this.reply_count = source["reply_count"]; + this.source = source["source"]; + this.facets = source["facets"]; + this.indexed_at = this.convertValues(source["indexed_at"], null); + this.rank = source["rank"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100755 new mode 100644 diff --git a/main.go b/main.go index 155823c..d28808a 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ func main() { BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 1}, OnStartup: app.startup, OnShutdown: app.shutdown, - Bind: []any{app, app.authService, app.indexService}, + Bind: []any{app, app.authService, app.indexService, app.searchService}, }) if err != nil { diff --git a/search_service.go b/search_service.go new file mode 100644 index 0000000..4cc5c7e --- /dev/null +++ b/search_service.go @@ -0,0 +1,19 @@ +package main + +// SearchService provides search functionality via Wails bindings +type SearchService struct{} + +// NewSearchService creates a new SearchService instance +func NewSearchService() *SearchService { + return &SearchService{} +} + +// Search performs an FTS5 search with BM25 ranking and optional source filter +func (s *SearchService) Search(query string, source string) ([]SearchResult, error) { + return SearchPosts(query, source) +} + +// CountPosts returns the total number of indexed posts +func (s *SearchService) CountPosts() (int, error) { + return CountPosts() +}