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: add post detail panel

* logout functionality

* fix logging service

+949 -232
+10 -5
README.md
··· 1 1 <!-- markdownlint-disable MD033 --> 2 2 # bsky-browser 3 3 4 - Desktop app for searching your Bluesky bookmarks and likes with a local SQLite index, full-text search, rich-text facet rendering, and a built-in log viewer. 4 + A desktop app for searching your Bluesky bookmarks and likes. 5 + 6 + > I made this to stop having to `CTRL`/`CMD`+`F` and infinite scroll through my saved 7 + > and liked posts. 5 8 6 9 ## What It Does 7 10 ··· 14 17 15 18 ## Screenshots 16 19 17 - ![Placeholder for the login screen showing the Bluesky handle field, authentication button, and dark desktop styling](https://placehold.co/1600x1000/111111/EAEAEA?text=Login+Screen) 20 + ![login screen showing the Bluesky handle field, authentication button, and dark desktop styling](./docs/login.png) 21 + 22 + ![filtered results](./docs/filtered.png) 18 23 19 - ![Placeholder for the main search view showing indexed post count, search bar, source filter, and results table after a successful refresh](https://placehold.co/1600x1000/111111/EAEAEA?text=Main+Search+View) 24 + ![main search view showing indexed post count, search bar, source filter, and results table after a successful refresh](./docs/main.png) 20 25 21 - ![Placeholder for a results table row with rendered rich-text facets such as links, mentions, and hashtags inside post text](https://placehold.co/1600x1000/111111/EAEAEA?text=Facet+Rendering) 26 + ![results table row with rendered rich-text facets such as links, mentions, and hashtags inside post text](./docs/facet.png) 22 27 23 - ![Placeholder for the bottom progress bar and log viewer during an active indexing run with info, warn, and error lines visible](https://placehold.co/1600x1000/111111/EAEAEA?text=Logs+and+Progress) 28 + ![bottom progress bar and log viewer during an active indexing run with info, warn, and error lines visible](./docs/progress.png) 24 29 25 30 ## Usage 26 31
+6 -4
app.go
··· 40 40 if err := a.logService.Initialize(); err != nil { 41 41 runtime.LogErrorf(a.ctx, "failed to initialize log service: %v", err) 42 42 } else { 43 - // Initialize the global logger with our log service 44 43 InitLogger(a.logService) 45 44 LogInfo("Application started") 46 45 } 47 46 48 47 dbPath := getDBPath() 49 48 if err := Open(dbPath); err != nil { 49 + LogErrorf("failed to open database: %v", err) 50 50 runtime.LogErrorf(a.ctx, "failed to open database: %v", err) 51 51 return 52 52 } 53 53 54 54 if a.authService.IsAuthenticated() { 55 55 if err := a.authService.RefreshSession(); err != nil { 56 + LogWarnf("token refresh failed on startup: %v", err) 56 57 runtime.LogWarningf(a.ctx, "token refresh failed on startup: %v", err) 57 58 } 58 59 } ··· 60 61 61 62 // shutdown is called when the app shuts down 62 63 func (a *App) shutdown(ctx context.Context) { 63 - if err := a.logService.Close(); err != nil { 64 - runtime.LogErrorf(ctx, "failed to close log service: %v", err) 65 - } 66 64 if err := Close(); err != nil { 65 + LogErrorf("failed to close database: %v", err) 67 66 runtime.LogErrorf(ctx, "failed to close database: %v", err) 67 + } 68 + if err := a.logService.Close(); err != nil { 69 + runtime.LogErrorf(ctx, "failed to close log service: %v", err) 68 70 } 69 71 } 70 72
+37 -4
auth_service.go
··· 12 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/wailsapp/wails/v2/pkg/runtime" 16 15 ) 17 16 18 17 // AuthService provides authentication functionality via Wails bindings ··· 176 175 dir := &identity.BaseDirectory{} 177 176 ident, err := dir.LookupDID(context.Background(), did) 178 177 if err != nil { 179 - if s.ctx != nil { 180 - runtime.LogWarningf(s.ctx, "failed to resolve handle for %s: %v", auth.DID, err) 181 - } 178 + LogWarnf("failed to resolve handle for %s: %v", auth.DID, err) 182 179 return auth, nil 183 180 } 184 181 ··· 234 231 235 232 if err := UpsertAuth(authFromSessionData(session.Data, auth.Handle)); err != nil { 236 233 return fmt.Errorf("failed to persist refreshed session: %w", err) 234 + } 235 + 236 + return nil 237 + } 238 + 239 + // Logout revokes the current session when possible and clears local auth state. 240 + func (s *AuthService) Logout() error { 241 + auth, err := GetAuth() 242 + if err != nil { 243 + return fmt.Errorf("failed to load auth: %w", err) 244 + } 245 + if auth == nil { 246 + return nil 247 + } 248 + 249 + if auth.SessionID != "" { 250 + store := NewSQLiteOAuthStore() 251 + app := newOAuthApp(store) 252 + 253 + did, err := syntax.ParseDID(auth.DID) 254 + if err == nil { 255 + session, resumeErr := app.ResumeSession(context.Background(), did, auth.SessionID) 256 + if resumeErr == nil { 257 + if revokeErr := session.RevokeSession(context.Background()); revokeErr != nil { 258 + LogWarnf("failed to revoke remote session for %s: %v", auth.DID, revokeErr) 259 + } 260 + } else { 261 + LogWarnf("failed to resume session for logout (%s): %v", auth.DID, resumeErr) 262 + } 263 + } else { 264 + LogWarnf("failed to parse DID for logout (%s): %v", auth.DID, err) 265 + } 266 + } 267 + 268 + if err := ClearAuth(); err != nil { 269 + return fmt.Errorf("failed to clear auth: %w", err) 237 270 } 238 271 239 272 return nil
+165 -36
database.go
··· 19 19 20 20 // Open opens the database connection and runs migrations 21 21 func Open(dbPath string) error { 22 - fmt.Printf("opening database: %s\n", dbPath) 22 + LogInfof("opening database: %s", dbPath) 23 23 24 24 dir := filepath.Dir(dbPath) 25 25 if err := os.MkdirAll(dir, 0755); err != nil { 26 - return fmt.Errorf("failed to create database directory: %w", err) 26 + return wrapDBError("failed to create database directory", err) 27 27 } 28 28 29 29 var err error 30 30 db, err = sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)") 31 31 if err != nil { 32 - return fmt.Errorf("failed to open database: %w", err) 32 + return wrapDBError("failed to open database", err) 33 33 } 34 34 35 35 if err := db.Ping(); err != nil { 36 - return fmt.Errorf("failed to ping database: %w", err) 36 + return wrapDBError("failed to ping database", err) 37 37 } 38 38 39 39 _, err = db.Exec("PRAGMA journal_mode=WAL") 40 40 if err != nil { 41 - return fmt.Errorf("failed to enable WAL mode: %w", err) 41 + return wrapDBError("failed to enable WAL mode", err) 42 42 } 43 43 44 - fmt.Println("database connection established with WAL mode") 44 + LogInfo("database connection established with WAL mode") 45 45 46 46 if err := runMigrations(); err != nil { 47 - return fmt.Errorf("failed to run migrations: %w", err) 47 + return wrapDBError("failed to run migrations", err) 48 48 } 49 49 50 - fmt.Println("database migrations completed successfully") 50 + LogInfo("database migrations completed successfully") 51 51 return nil 52 52 } 53 53 54 54 func runMigrations() error { 55 55 content, err := migrationsFS.ReadFile("migrations/000_initial_schema.sql") 56 56 if err != nil { 57 - return fmt.Errorf("failed to read migration: %w", err) 57 + return wrapDBError("failed to read migration", err) 58 58 } 59 59 60 60 if _, err := db.Exec(string(content)); err != nil { 61 - return fmt.Errorf("failed to execute migration: %w", err) 61 + return wrapDBError("failed to execute migration", err) 62 + } 63 + 64 + if err := ensureSchemaCompatibility(); err != nil { 65 + return wrapDBError("failed to upgrade schema", err) 66 + } 67 + 68 + return nil 69 + } 70 + 71 + func ensureSchemaCompatibility() error { 72 + columnsByTable := map[string][]struct { 73 + name string 74 + definition string 75 + }{ 76 + "posts": { 77 + {name: "facets", definition: "TEXT"}, 78 + }, 79 + "auth": { 80 + {name: "session_id", definition: "TEXT"}, 81 + {name: "auth_server_url", definition: "TEXT"}, 82 + {name: "auth_server_token_endpoint", definition: "TEXT"}, 83 + {name: "auth_server_revocation_endpoint", definition: "TEXT"}, 84 + {name: "dpop_auth_nonce", definition: "TEXT"}, 85 + {name: "dpop_host_nonce", definition: "TEXT"}, 86 + {name: "dpop_private_key", definition: "TEXT"}, 87 + }, 88 + } 89 + 90 + for table, columns := range columnsByTable { 91 + exists, err := tableExists(table) 92 + if err != nil { 93 + return err 94 + } 95 + if !exists { 96 + continue 97 + } 98 + 99 + for _, column := range columns { 100 + hasColumn, err := columnExists(table, column.name) 101 + if err != nil { 102 + return err 103 + } 104 + if hasColumn { 105 + continue 106 + } 107 + 108 + query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column.name, column.definition) 109 + if _, err := db.Exec(query); err != nil { 110 + return wrapDBError("failed to add "+table+"."+column.name, err) 111 + } 112 + } 62 113 } 63 114 64 115 return nil 116 + } 117 + 118 + func tableExists(table string) (bool, error) { 119 + var count int 120 + if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&count); err != nil { 121 + return false, err 122 + } 123 + return count > 0, nil 124 + } 125 + 126 + func columnExists(table, column string) (bool, error) { 127 + rows, err := db.Query("SELECT name FROM pragma_table_info(?)", table) 128 + if err != nil { 129 + return false, err 130 + } 131 + defer rows.Close() 132 + 133 + for rows.Next() { 134 + var name string 135 + 136 + if err := rows.Scan(&name); err != nil { 137 + return false, err 138 + } 139 + 140 + if name == column { 141 + return true, nil 142 + } 143 + } 144 + 145 + return false, rows.Err() 65 146 } 66 147 67 148 // Close closes the database connection 68 149 func Close() error { 69 - fmt.Println("closing database connection") 150 + LogInfo("closing database connection") 70 151 if db != nil { 71 152 err := db.Close() 72 153 if err != nil { 73 - fmt.Printf("failed to close database: %v\n", err) 154 + LogErrorf("failed to close database: %v", err) 74 155 return err 75 156 } 76 - fmt.Println("database connection closed") 157 + LogInfo("database connection closed") 77 158 } 78 159 return nil 79 160 } ··· 90 171 91 172 // InsertPost inserts a post into the database 92 173 func InsertPost(post *Post) error { 93 - fmt.Printf("inserting post: %s by %s\n", post.URI, post.AuthorHandle) 174 + LogInfof("inserting post: %s by %s", post.URI, post.AuthorHandle) 94 175 95 176 exists, err := PostExists(post.URI) 96 177 if err != nil { 97 - fmt.Printf("failed to check if post exists: %s, error: %v\n", post.URI, err) 178 + LogErrorf("failed to check if post exists: %s, error: %v", post.URI, err) 98 179 return err 99 180 } 100 181 101 182 if exists { 102 - fmt.Printf("skipping already indexed post: %s\n", post.URI) 183 + LogDebugf("skipping already indexed post: %s", post.URI) 103 184 return nil 104 185 } 105 186 ··· 135 216 ) 136 217 137 218 if err != nil { 138 - fmt.Printf("failed to insert post: %s, error: %v\n", post.URI, err) 219 + LogErrorf("failed to insert post: %s, error: %v", post.URI, err) 139 220 } 140 221 141 222 return err ··· 143 224 144 225 // UpsertAuth inserts or updates auth information 145 226 func UpsertAuth(auth *Auth) error { 146 - fmt.Printf("upserting auth: %s (%s)\n", auth.DID, auth.Handle) 227 + LogInfof("upserting auth: %s (%s)", auth.DID, auth.Handle) 147 228 148 229 query := ` 149 230 INSERT INTO auth (did, handle, access_jwt, refresh_jwt, pds_url, session_id, ··· 181 262 ) 182 263 183 264 if err != nil { 184 - fmt.Printf("failed to upsert auth: %s, error: %v\n", auth.DID, err) 265 + LogErrorf("failed to upsert auth: %s, error: %v", auth.DID, err) 185 266 } 186 267 187 268 return err 188 269 } 189 270 271 + // ClearAuth removes all persisted auth rows for this desktop client. 272 + func ClearAuth() error { 273 + _, err := db.Exec("DELETE FROM auth") 274 + return err 275 + } 276 + 190 277 // GetAuth loads the auth record from the database 191 278 func GetAuth() (*Auth, error) { 192 - fmt.Println("loading auth from database") 279 + LogInfo("loading auth from database") 193 280 194 281 query := `SELECT did, handle, access_jwt, refresh_jwt, pds_url, session_id, 195 282 auth_server_url, auth_server_token_endpoint, auth_server_revocation_endpoint, ··· 201 288 auth, err := getAuthByQuery(query) 202 289 203 290 if err == sql.ErrNoRows { 204 - fmt.Println("no auth record found in database") 291 + LogInfo("no auth record found in database") 205 292 return nil, nil 206 293 } 207 294 if err != nil { 208 - fmt.Printf("failed to load auth: %v\n", err) 295 + LogErrorf("failed to load auth: %v", err) 209 296 return nil, err 210 297 } 211 298 212 - fmt.Printf("auth loaded successfully: %s (%s)\n", auth.DID, auth.Handle) 299 + LogInfof("auth loaded successfully: %s (%s)", auth.DID, auth.Handle) 213 300 return auth, nil 214 301 } 215 302 ··· 279 366 auth.DPoPPrivateKey = dpopPrivateKey.String 280 367 } 281 368 282 - auth.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) 369 + auth.UpdatedAt = parseStoredTime(updatedAt) 283 370 return &auth, nil 284 371 } 285 372 ··· 290 377 query = "" 291 378 } 292 379 293 - fmt.Printf("searching posts: query=%s, source=%s\n", query, source) 380 + LogInfof("searching posts: query=%s, source=%s", query, source) 294 381 295 382 if query == "" { 296 383 return listRecentPosts(source) ··· 310 397 311 398 rows, err := db.Query(sqlQuery, query, source, source) 312 399 if err != nil { 313 - fmt.Printf("failed to execute search query: %v\n", err) 400 + LogErrorf("failed to execute search query: %v", err) 314 401 return nil, err 315 402 } 316 403 defer rows.Close() ··· 338 425 return nil, err 339 426 } 340 427 341 - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) 342 - r.IndexedAt, _ = time.Parse("2006-01-02 15:04:05", indexedAt) 428 + r.CreatedAt = parseStoredTime(createdAt) 429 + r.IndexedAt = parseStoredTime(indexedAt) 343 430 results = append(results, r) 344 431 } 345 432 346 - fmt.Printf("search completed: %d results\n", len(results)) 433 + LogInfof("search completed: %d results", len(results)) 347 434 return results, rows.Err() 348 435 } 349 436 ··· 357 444 LIMIT 25 358 445 `, source, source) 359 446 if err != nil { 360 - fmt.Printf("failed to list recent posts: %v\n", err) 447 + LogErrorf("failed to list recent posts: %v", err) 361 448 return nil, err 362 449 } 363 450 defer rows.Close() ··· 384 471 return nil, err 385 472 } 386 473 387 - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) 388 - r.IndexedAt, _ = time.Parse("2006-01-02 15:04:05", indexedAt) 474 + r.CreatedAt = parseStoredTime(createdAt) 475 + r.IndexedAt = parseStoredTime(indexedAt) 389 476 results = append(results, r) 390 477 } 391 478 392 - fmt.Printf("browse completed: %d results\n", len(results)) 479 + LogInfof("browse completed: %d results", len(results)) 393 480 return results, rows.Err() 394 481 } 395 482 483 + func parseStoredTime(value string) time.Time { 484 + if value == "" { 485 + return time.Time{} 486 + } 487 + 488 + layouts := []string{ 489 + time.RFC3339Nano, 490 + time.RFC3339, 491 + "2006-01-02 15:04:05.999999999-07:00", 492 + "2006-01-02 15:04:05.999999999Z07:00", 493 + "2006-01-02 15:04:05.999999999", 494 + "2006-01-02 15:04:05 -0700 MST", 495 + "2006-01-02 15:04:05", 496 + } 497 + 498 + for _, layout := range layouts { 499 + parsed, err := time.Parse(layout, value) 500 + if err == nil { 501 + return parsed 502 + } 503 + } 504 + 505 + return time.Time{} 506 + } 507 + 396 508 // CountPosts returns the total number of posts in the database 397 509 func CountPosts() (int, error) { 398 - fmt.Println("counting posts in database") 510 + LogInfo("counting posts in database") 399 511 400 512 var count int 401 513 err := db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count) 402 514 if err != nil { 403 - fmt.Printf("failed to count posts: %v\n", err) 515 + LogErrorf("failed to count posts: %v", err) 404 516 return 0, err 405 517 } 406 518 407 - fmt.Printf("post count: %d\n", count) 519 + LogInfof("post count: %d", count) 408 520 return count, nil 409 521 } 522 + 523 + func wrapDBError(message string, err error) error { 524 + return &dbError{message: message, err: err} 525 + } 526 + 527 + type dbError struct { 528 + message string 529 + err error 530 + } 531 + 532 + func (e *dbError) Error() string { 533 + return e.message + ": " + e.err.Error() 534 + } 535 + 536 + func (e *dbError) Unwrap() error { 537 + return e.err 538 + }
+69
database_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "path/filepath" 6 7 "testing" 7 8 "time" 8 9 9 10 "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 + _ "modernc.org/sqlite" 11 13 ) 12 14 13 15 func openTestDB(t *testing.T) { ··· 64 66 } 65 67 if results[0].URI != posts[1].URI { 66 68 t.Fatalf("SearchPosts(empty) first URI = %q, want %q", results[0].URI, posts[1].URI) 69 + } 70 + if results[0].CreatedAt.IsZero() { 71 + t.Fatal("SearchPosts(empty) CreatedAt is zero, want parsed timestamp") 67 72 } 68 73 69 74 starResults, err := SearchPosts("*", "saved") ··· 137 142 t.Fatalf("GetSession() after delete = (%v, %v), want error", deleted, err) 138 143 } 139 144 } 145 + 146 + func TestOpenMigratesLegacyPostsTableWithoutFacets(t *testing.T) { 147 + dbPath := filepath.Join(t.TempDir(), "legacy.db") 148 + 149 + legacyDB, err := sql.Open("sqlite", dbPath) 150 + if err != nil { 151 + t.Fatalf("sql.Open() error = %v", err) 152 + } 153 + 154 + legacySchema := ` 155 + CREATE TABLE posts ( 156 + uri TEXT PRIMARY KEY, 157 + cid TEXT NOT NULL, 158 + author_did TEXT NOT NULL, 159 + author_handle TEXT NOT NULL, 160 + text TEXT NOT NULL DEFAULT '', 161 + created_at DATETIME NOT NULL, 162 + like_count INTEGER DEFAULT 0, 163 + repost_count INTEGER DEFAULT 0, 164 + reply_count INTEGER DEFAULT 0, 165 + source TEXT NOT NULL CHECK(source IN ('saved', 'liked')), 166 + indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP 167 + ); 168 + ` 169 + 170 + if _, err := legacyDB.Exec(legacySchema); err != nil { 171 + t.Fatalf("creating legacy schema failed: %v", err) 172 + } 173 + if err := legacyDB.Close(); err != nil { 174 + t.Fatalf("legacyDB.Close() error = %v", err) 175 + } 176 + 177 + if err := Open(dbPath); err != nil { 178 + t.Fatalf("Open() error = %v", err) 179 + } 180 + t.Cleanup(func() { 181 + if err := Close(); err != nil { 182 + t.Fatalf("Close() error = %v", err) 183 + } 184 + }) 185 + 186 + hasColumn, err := columnExists("posts", "facets") 187 + if err != nil { 188 + t.Fatalf("columnExists() error = %v", err) 189 + } 190 + if !hasColumn { 191 + t.Fatal("posts.facets missing after migration") 192 + } 193 + 194 + post := &Post{ 195 + URI: "at://did:plc:test/app.bsky.feed.post/legacy", 196 + CID: "cid-legacy", 197 + AuthorDID: "did:plc:test", 198 + AuthorHandle: "legacy.test", 199 + Text: "legacy post", 200 + CreatedAt: time.Now().UTC(), 201 + Source: "saved", 202 + Facets: `[]`, 203 + } 204 + 205 + if err := InsertPost(post); err != nil { 206 + t.Fatalf("InsertPost() after migration error = %v", err) 207 + } 208 + }
docs/facet.png

This is a binary file and will not be displayed.

docs/filtered.png

This is a binary file and will not be displayed.

docs/login.png

This is a binary file and will not be displayed.

docs/main.png

This is a binary file and will not be displayed.

docs/progress.png

This is a binary file and will not be displayed.

+5 -3
frontend/.prettierrc
··· 1 1 { 2 - "bracketSameLine": true, 3 - "objectWrap": "collapse", 4 2 "printWidth": 120, 5 - "tabWidth": 2 3 + "objectWrap": "collapse", 4 + "tabWidth": 2, 5 + "bracketSameLine": true, 6 + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 6 8 }
+7 -2
frontend/package.json
··· 7 7 "dev": "vite", 8 8 "build": "vite build", 9 9 "preview": "vite preview", 10 - "check": "svelte-check --tsconfig ./tsconfig.json" 10 + "check": "svelte-check --tsconfig ./tsconfig.json", 11 + "format": "prettier --write \"src/**/*.ts\" \"src/**/*.svelte\"", 12 + "format:check": "prettier --check \"src/**/*.ts\" \"src/**/*.svelte\"" 11 13 }, 12 14 "devDependencies": { 13 15 "@egoist/tailwindcss-icons": "^1.9.2", ··· 17 19 "svelte-check": "^4.4.5", 18 20 "tslib": "^2.8.1", 19 21 "typescript": "^5.9.3", 20 - "vite": "^7.2.6" 22 + "vite": "^7.2.6", 23 + "prettier": "^3.8.1", 24 + "prettier-plugin-svelte": "^3.4.1", 25 + "prettier-plugin-tailwindcss": "0.7.2" 21 26 }, 22 27 "dependencies": { 23 28 "@fontsource-variable/geist": "^5.2.8",
+1 -1
frontend/package.json.md5
··· 1 - 02bc6d89a1b36e6363147a6cafdca3d7 1 + 483486c27162a0e6dea53e76233fd2fb
+88
frontend/pnpm-lock.yaml
··· 33 33 '@sveltejs/vite-plugin-svelte': 34 34 specifier: ^6.2.4 35 35 version: 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) 36 + prettier: 37 + specifier: ^3.8.1 38 + version: 3.8.1 39 + prettier-plugin-svelte: 40 + specifier: ^3.4.1 41 + version: 3.5.1(prettier@3.8.1)(svelte@5.53.12) 42 + prettier-plugin-tailwindcss: 43 + specifier: 0.7.2 44 + version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.12))(prettier@3.8.1) 36 45 svelte: 37 46 specifier: ^5.53.12 38 47 version: 5.53.12 ··· 679 688 resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} 680 689 engines: {node: ^10 || ^12 || >=14} 681 690 691 + prettier-plugin-svelte@3.5.1: 692 + resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==} 693 + peerDependencies: 694 + prettier: ^3.0.0 695 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 696 + 697 + prettier-plugin-tailwindcss@0.7.2: 698 + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} 699 + engines: {node: '>=20.19'} 700 + peerDependencies: 701 + '@ianvs/prettier-plugin-sort-imports': '*' 702 + '@prettier/plugin-hermes': '*' 703 + '@prettier/plugin-oxc': '*' 704 + '@prettier/plugin-pug': '*' 705 + '@shopify/prettier-plugin-liquid': '*' 706 + '@trivago/prettier-plugin-sort-imports': '*' 707 + '@zackad/prettier-plugin-twig': '*' 708 + prettier: ^3.0 709 + prettier-plugin-astro: '*' 710 + prettier-plugin-css-order: '*' 711 + prettier-plugin-jsdoc: '*' 712 + prettier-plugin-marko: '*' 713 + prettier-plugin-multiline-arrays: '*' 714 + prettier-plugin-organize-attributes: '*' 715 + prettier-plugin-organize-imports: '*' 716 + prettier-plugin-sort-imports: '*' 717 + prettier-plugin-svelte: '*' 718 + peerDependenciesMeta: 719 + '@ianvs/prettier-plugin-sort-imports': 720 + optional: true 721 + '@prettier/plugin-hermes': 722 + optional: true 723 + '@prettier/plugin-oxc': 724 + optional: true 725 + '@prettier/plugin-pug': 726 + optional: true 727 + '@shopify/prettier-plugin-liquid': 728 + optional: true 729 + '@trivago/prettier-plugin-sort-imports': 730 + optional: true 731 + '@zackad/prettier-plugin-twig': 732 + optional: true 733 + prettier-plugin-astro: 734 + optional: true 735 + prettier-plugin-css-order: 736 + optional: true 737 + prettier-plugin-jsdoc: 738 + optional: true 739 + prettier-plugin-marko: 740 + optional: true 741 + prettier-plugin-multiline-arrays: 742 + optional: true 743 + prettier-plugin-organize-attributes: 744 + optional: true 745 + prettier-plugin-organize-imports: 746 + optional: true 747 + prettier-plugin-sort-imports: 748 + optional: true 749 + prettier-plugin-svelte: 750 + optional: true 751 + 752 + prettier@3.8.1: 753 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 754 + engines: {node: '>=14'} 755 + hasBin: true 756 + 682 757 readdirp@4.1.2: 683 758 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 684 759 engines: {node: '>= 14.18.0'} ··· 1245 1320 nanoid: 3.3.11 1246 1321 picocolors: 1.1.1 1247 1322 source-map-js: 1.2.1 1323 + 1324 + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.12): 1325 + dependencies: 1326 + prettier: 3.8.1 1327 + svelte: 5.53.12 1328 + 1329 + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.12))(prettier@3.8.1): 1330 + dependencies: 1331 + prettier: 3.8.1 1332 + optionalDependencies: 1333 + prettier-plugin-svelte: 3.5.1(prettier@3.8.1)(svelte@5.53.12) 1334 + 1335 + prettier@3.8.1: {} 1248 1336 1249 1337 readdirp@4.1.2: {} 1250 1338
+81 -26
frontend/src/App.svelte
··· 4 4 import "@fontsource-variable/lora"; 5 5 import { onMount } from "svelte"; 6 6 import { fade, slide } from "svelte/transition"; 7 - import { Login, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; 7 + import { Login, Logout, Whoami, IsAuthenticated } from "../wailsjs/go/main/AuthService"; 8 8 import { Refresh, IsIndexing } from "../wailsjs/go/main/IndexService"; 9 9 import { Search, CountPosts } from "../wailsjs/go/main/SearchService"; 10 10 import { EventsOn } from "../wailsjs/runtime/runtime"; ··· 15 15 import { toaster } from "./lib/stores/toast.svelte"; 16 16 import EmptyState from "./lib/components/EmptyState.svelte"; 17 17 import ProgressBar from "./lib/components/ProgressBar.svelte"; 18 + import PostDetailPanel from "./lib/components/PostDetailPanel.svelte"; 19 + import { parseDateValue } from "./lib/date"; 18 20 import type { main } from "../wailsjs/go/models"; 19 21 import type { IndexStats } from "./lib/types"; 20 22 ··· 37 39 let sortDirection = $state<"asc" | "desc">("desc"); 38 40 let isSearching = $state(false); 39 41 let showLogs = $state(false); 42 + let selectedPost = $state<main.SearchResult | null>(null); 40 43 41 44 onMount(() => { 42 45 document.addEventListener("keydown", handleGlobalKeydown); ··· 129 132 } 130 133 } 131 134 135 + async function handleLogout() { 136 + try { 137 + await Logout(); 138 + isLoggedIn = false; 139 + authInfo = null; 140 + searchResults = []; 141 + totalPosts = 0; 142 + searchQuery = ""; 143 + searchSource = ""; 144 + handle = ""; 145 + selectedPost = null; 146 + status = "Please log in to continue"; 147 + toaster.success("Logged out"); 148 + } catch (err) { 149 + toaster.error(`Logout failed: ${err}`); 150 + } 151 + } 152 + 132 153 async function loadPosts() { 133 154 try { 134 155 totalPosts = await CountPosts(); ··· 144 165 try { 145 166 const results = await Search(query.trim(), source); 146 167 searchResults = sortResults(results); 168 + if (selectedPost && !results.some((post) => post.uri === selectedPost?.uri)) { 169 + selectedPost = null; 170 + } 147 171 } catch (err) { 148 172 console.error("Search failed:", err); 149 173 searchResults = []; ··· 159 183 let bVal: any = b[sortColumn as keyof main.SearchResult]; 160 184 161 185 if (sortColumn === "created_at" || sortColumn === "indexed_at") { 162 - aVal = aVal ? new Date(aVal).getTime() : 0; 163 - bVal = bVal ? new Date(bVal).getTime() : 0; 186 + aVal = parseDateValue(aVal)?.getTime() ?? 0; 187 + bVal = parseDateValue(bVal)?.getTime() ?? 0; 164 188 } 165 189 166 190 if (typeof aVal === "number" && typeof bVal === "number") { ··· 220 244 221 245 <Toaster /> 222 246 223 - <main class="min-h-screen bg-black text-bright flex flex-col"> 247 + <main class="text-bright flex min-h-screen flex-col bg-black"> 224 248 {#if !isLoggedIn} 225 249 <!-- Login View --> 226 - <div class="flex-1 flex items-center justify-center p-4" transition:fade={{ duration: 300 }}> 250 + <div class="flex flex-1 items-center justify-center p-4" transition:fade={{ duration: 300 }}> 227 251 <div class="w-full max-w-md"> 228 - <div class="text-center mb-8"> 229 - <h1 class="font-serif text-4xl mb-2">bsky-browser</h1> 230 - <p class="font-mono text-muted text-sm">Search your Bluesky bookmarks and likes</p> 252 + <div class="mb-8 text-center"> 253 + <h1 class="mb-2 font-serif text-4xl">bsky-browser</h1> 254 + <p class="text-muted font-mono text-sm">Search your Bluesky bookmarks and likes</p> 231 255 </div> 232 256 233 - <div class="bg-surface border border-outline rounded-lg p-6"> 257 + <div class="bg-surface border-outline rounded-lg border p-6"> 234 258 <div class="space-y-4"> 235 259 <div> 236 - <label for="handle" class="block font-sans text-sm text-muted mb-2"> Bluesky Handle </label> 260 + <label for="handle" class="text-muted mb-2 block font-sans text-sm"> Bluesky Handle </label> 237 261 <input 238 262 id="handle" 239 263 type="text" ··· 241 265 bind:value={handle} 242 266 onkeydown={handleKeydown} 243 267 disabled={isLoading} 244 - 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" /> 268 + class="border-outline text-bright w-full rounded border bg-black px-4 py-2 font-mono text-sm placeholder-[#333] focus:border-[#333] focus:outline-none disabled:opacity-50" /> 245 269 </div> 246 270 247 271 <button 248 272 onclick={handleLogin} 249 273 disabled={isLoading || !handle.trim()} 250 - class="w-full bg-surface border border-outline hover:bg-outline text-bright font-sans py-2 px-4 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 274 + class="bg-surface border-outline hover:bg-outline text-bright w-full rounded border px-4 py-2 font-sans transition-colors disabled:cursor-not-allowed disabled:opacity-50"> 251 275 {#if isLoading} 252 276 <span class="animate-pulse">Authenticating...</span> 253 277 {:else} ··· 257 281 </div> 258 282 259 283 {#if status} 260 - <div class="mt-4 p-3 bg-black border border-outline rounded" transition:slide={{ duration: 200 }}> 261 - <p class="font-mono text-xs text-muted">{status}</p> 284 + <div class="border-outline mt-4 rounded border bg-black p-3" transition:slide={{ duration: 200 }}> 285 + <p class="text-muted font-mono text-xs">{status}</p> 262 286 </div> 263 287 {/if} 264 288 </div> ··· 266 290 </div> 267 291 {:else} 268 292 <!-- Main View --> 269 - <div class="flex-1 flex flex-col"> 293 + <div class="flex flex-1 flex-col"> 270 294 <!-- Header --> 271 - <header class="border-b border-outline bg-surface px-6 py-4"> 295 + <header class="border-secondary bg-surface border-b px-6 py-4"> 272 296 <div class="flex items-center justify-between"> 273 297 <div> 274 298 <h1 class="font-serif text-xl">bsky-browser</h1> 275 - <p class="font-mono text-xs text-muted">@{authInfo?.handle} · {totalPosts} posts indexed</p> 299 + <p class="text-muted font-mono text-xs">@{authInfo?.handle} · {totalPosts} posts indexed</p> 276 300 </div> 277 301 278 302 <div class="flex items-center gap-3"> 279 303 <button 280 304 onclick={() => (showLogs = !showLogs)} 281 - class="font-mono text-xs px-3 py-2 rounded bg-surface border border-outline hover:bg-outline text-bright transition-colors {showLogs 305 + class="bg-surface border-outline hover:bg-outline text-bright rounded border px-3 py-2 font-mono text-xs transition-colors {showLogs 282 306 ? 'bg-[#333]' 283 307 : ''}"> 284 308 {#if showLogs} ··· 295 319 </button> 296 320 297 321 <div class="flex items-center gap-2"> 298 - <label for="refreshLimit" class="font-sans text-xs text-muted">Limit:</label> 322 + <label for="refreshLimit" class="text-muted font-sans text-xs">Limit:</label> 299 323 <input 300 324 id="refreshLimit" 301 325 type="number" 302 326 min="0" 303 327 bind:value={refreshLimit} 304 328 disabled={isIndexing} 305 - class="w-20 bg-black border border-outline rounded px-2 py-1 font-mono text-sm text-bright focus:outline-none focus:border-[#333] disabled:opacity-50" /> 329 + class="border-outline text-bright w-20 rounded border bg-black px-2 py-1 font-mono text-sm focus:border-[#333] focus:outline-none disabled:opacity-50" /> 306 330 </div> 307 331 308 332 <button 309 333 onclick={handleRefresh} 310 334 disabled={isIndexing} 311 - class="bg-surface border border-outline hover:bg-outline text-bright font-sans py-2 px-4 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 335 + class="bg-surface border-outline hover:bg-outline text-bright rounded border px-4 py-2 font-sans transition-colors disabled:cursor-not-allowed disabled:opacity-50"> 312 336 {#if isIndexing} 313 337 <span class="animate-pulse">Refreshing...</span> 314 338 {:else} ··· 318 342 </span> 319 343 {/if} 320 344 </button> 345 + 346 + <button 347 + onclick={handleLogout} 348 + class="bg-surface border-outline hover:bg-outline text-bright rounded border px-4 py-2 font-sans transition-colors"> 349 + <span class="flex items-center gap-2"> 350 + <i class="i-ri-logout-box-r-line"></i> 351 + <span>Logout</span> 352 + </span> 353 + </button> 321 354 </div> 322 355 </div> 323 356 </header> 324 357 325 - <div class="px-6 py-4 border-b border-secondary"> 358 + <div class="border-outline border-b px-6 py-4"> 326 359 <SearchBar bind:query={searchQuery} bind:source={searchSource} onSearch={performSearch} /> 327 360 </div> 328 361 329 - <main class="flex-1 p-6 overflow-hidden"> 362 + <main class="flex-1 overflow-hidden p-6"> 330 363 {#if isSearching} 331 - <div class="flex items-center justify-center h-full"> 332 - <span class="font-sans text-muted animate-pulse">Searching...</span> 364 + <div class="flex h-full items-center justify-center"> 365 + <span class="text-muted animate-pulse font-sans">Searching...</span> 333 366 </div> 334 367 {:else if totalPosts === 0} 335 368 <EmptyState onRefresh={handleRefresh} /> 336 369 {:else} 337 - <DataTable posts={searchResults} {sortColumn} {sortDirection} onSort={handleSort} /> 370 + <div class="flex h-full min-h-0 flex-col gap-6 xl:flex-row"> 371 + <div class="min-h-0 min-w-0 flex-1"> 372 + <DataTable 373 + posts={searchResults} 374 + {sortColumn} 375 + {sortDirection} 376 + selectedPostURI={selectedPost?.uri ?? null} 377 + onSort={handleSort} 378 + onOpenPost={(post) => { 379 + selectedPost = post; 380 + }} /> 381 + </div> 382 + 383 + {#if selectedPost} 384 + <div class="min-h-88 xl:h-full" transition:slide={{ axis: "x", duration: 220 }}> 385 + <PostDetailPanel 386 + post={selectedPost} 387 + onClose={() => { 388 + selectedPost = null; 389 + }} /> 390 + </div> 391 + {/if} 392 + </div> 338 393 {/if} 339 394 </main> 340 395
+134 -56
frontend/src/lib/components/DataTable.svelte
··· 1 1 <script lang="ts"> 2 - import { BrowserOpenURL } from "../../../wailsjs/runtime/runtime"; 3 2 import type { main } from "../../../wailsjs/go/models"; 3 + import { formatShortDate } from "../date"; 4 4 import PostText from "./PostText.svelte"; 5 5 6 6 interface Props { ··· 8 8 sortColumn: string; 9 9 sortDirection: "asc" | "desc"; 10 10 onSort: (column: string) => void; 11 + onOpenPost: (post: main.SearchResult) => void; 12 + selectedPostURI?: string | null; 11 13 } 12 14 13 - let { posts, sortColumn, sortDirection, onSort }: Props = $props(); 15 + let { posts, sortColumn, sortDirection, onSort, onOpenPost, selectedPostURI = null }: Props = $props(); 14 16 15 17 const columns = [ 16 - { key: "author_handle", label: "Author", width: "w-32" }, 17 - { key: "text", label: "Text", width: "flex-1" }, 18 - { key: "created_at", label: "Created", width: "w-32" }, 19 - { key: "like_count", label: "♥", width: "w-16" }, 20 - { key: "repost_count", label: "🔁", width: "w-16" }, 21 - { key: "reply_count", label: "💬", width: "w-16" }, 22 - { key: "source", label: "Source", width: "w-20" }, 18 + { key: "author_handle", label: "Author", width: "w-36" }, 19 + { key: "text", label: "Text", width: "min-w-[32rem]" }, 20 + { key: "created_at", label: "Created", width: "w-36" }, 21 + { key: "like_count", label: "LIKE", width: "w-20" }, 22 + { key: "repost_count", label: "REPOST", width: "w-20" }, 23 + { key: "reply_count", label: "REPLY", width: "w-20" }, 24 + { key: "source", label: "Source", width: "w-28" }, 23 25 ]; 24 26 25 - function formatDate(dateStr: string): string { 26 - if (!dateStr) return "-"; 27 - const date = new Date(dateStr); 28 - return date.toLocaleDateString("en-US", { 29 - month: "short", 30 - day: "numeric", 31 - year: "numeric", 32 - }); 33 - } 27 + const pageSize = 12; 28 + let currentPage = $state(1); 34 29 35 - function truncateText(text: string, maxLength: number = 120): string { 36 - if (!text) return ""; 37 - if (text.length <= maxLength) return text; 38 - return text.slice(0, maxLength) + "..."; 39 - } 30 + let totalPages = $derived(Math.max(1, Math.ceil(posts.length / pageSize))); 31 + let paginatedPosts = $derived(posts.slice((currentPage - 1) * pageSize, currentPage * pageSize)); 32 + let pageStart = $derived(posts.length === 0 ? 0 : (currentPage - 1) * pageSize + 1); 33 + let pageEnd = $derived(Math.min(currentPage * pageSize, posts.length)); 34 + let visiblePages = $derived.by(() => { 35 + const pages: number[] = []; 36 + const start = Math.max(1, currentPage - 2); 37 + const end = Math.min(totalPages, currentPage + 2); 40 38 41 - /** 42 - * Convert at:// URI to bsky.app URL 43 - * at://did:plc:xxx/app.bsky.feed.post/xxx -> https://bsky.app/profile/did:plc:xxx/post/xxx 44 - */ 45 - function buildPostURL(uri: string): string { 46 - const match = uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/); 47 - if (match) { 48 - return `https://bsky.app/profile/${match[1]}/post/${match[2]}`; 39 + for (let page = start; page <= end; page += 1) { 40 + pages.push(page); 49 41 } 50 - return uri; 51 - } 52 42 53 - function handleRowClick(uri: string) { 54 - const url = buildPostURL(uri); 55 - BrowserOpenURL(url); 56 - } 43 + return pages; 44 + }); 45 + 46 + $effect(() => { 47 + posts; 48 + currentPage = 1; 49 + }); 50 + 51 + $effect(() => { 52 + if (currentPage > totalPages) { 53 + currentPage = totalPages; 54 + } 55 + }); 57 56 58 57 function getSortIcon(column: string): string { 59 58 if (sortColumn !== column) return "↕"; ··· 61 60 } 62 61 </script> 63 62 64 - <div class="border border-outline rounded-lg overflow-hidden bg-surface"> 65 - <div class="overflow-x-auto"> 66 - <table class="w-full"> 67 - <thead class="bg-black border-b border-outline"> 63 + {#snippet columnLabel(label: string)} 64 + {#if label === "LIKE"} 65 + <span class="flex-items-center"> 66 + <i class="i-ri-heart-line text-red-500"></i> 67 + </span> 68 + {:else if label === "REPOST"} 69 + <span class="flex-items-center"> 70 + <i class="i-ri-repeat-line text-blue-500"></i> 71 + </span> 72 + {:else if label === "REPLY"} 73 + <span class="flex-items-center"> 74 + <i class="i-ri-message-2-line text-green-500"></i> 75 + </span> 76 + {:else} 77 + <span>{label}</span> 78 + {/if} 79 + {/snippet} 80 + 81 + {#snippet sortIcon(column: string)} 82 + <span class="flex items-center"> 83 + {#if sortColumn !== column} 84 + <i class="i-ri-arrow-up-down-line"></i> 85 + {:else if sortDirection === "asc"} 86 + <i class="i-ri-arrow-up-line"></i> 87 + {:else} 88 + <i class="i-ri-arrow-down-line"></i> 89 + {/if} 90 + </span> 91 + {/snippet} 92 + 93 + <div 94 + class="border-outline bg-surface flex h-full min-h-0 flex-col overflow-hidden rounded-[1.25rem] border shadow-[0_18px_60px_rgba(0,0,0,0.35)]"> 95 + <div class="min-h-0 flex-1 overflow-auto"> 96 + <table class="w-full min-w-296 border-separate border-spacing-0"> 97 + <thead class="sticky top-0 z-10 bg-black/95 backdrop-blur"> 68 98 <tr> 69 99 {#each columns as column} 70 100 <th 71 - class="px-4 py-3 text-left font-sans text-xs text-muted cursor-pointer hover:text-bright select-none {column.width}" 101 + class="border-outline text-muted hover:text-bright cursor-pointer border-b px-4 py-3 text-left font-sans text-xs tracking-[0.16em] uppercase select-none {column.width}" 72 102 onclick={() => onSort(column.key)}> 73 103 <div class="flex items-center gap-1"> 74 - <span>{column.label}</span> 75 - <span class="font-mono text-[10px]">{getSortIcon(column.key)}</span> 104 + {@render columnLabel(column.label)} 105 + {@render sortIcon(column.key)} 76 106 </div> 77 107 </th> 78 108 {/each} 79 109 </tr> 80 110 </thead> 81 111 82 - <tbody class="divide-y divide-outline"> 83 - {#each posts as post} 84 - <tr class="hover:bg-black/50 cursor-pointer transition-colors group" onclick={() => handleRowClick(post.uri)}> 85 - <td class="px-4 py-3 font-mono text-xs text-muted truncate"> 112 + <tbody class="divide-outline divide-y"> 113 + {#each paginatedPosts as post} 114 + <tr 115 + class="group cursor-pointer transition-colors {selectedPostURI === post.uri 116 + ? 'bg-primary/10' 117 + : 'hover:bg-black/50'}" 118 + onclick={() => onOpenPost(post)}> 119 + <td class="text-muted truncate px-4 py-3 font-mono text-xs"> 86 120 @{post.author_handle} 87 121 </td> 88 122 89 - <td class="px-4 py-3 font-mono text-sm text-bright"> 123 + <td class="text-bright px-4 py-3 font-mono text-sm"> 90 124 <div class="line-clamp-2"> 91 125 <PostText text={post.text} facetsJson={post.facets} maxLength={120} /> 92 126 </div> 93 127 </td> 94 128 95 - <td class="px-4 py-3 font-mono text-xs text-muted"> 96 - {formatDate(post.created_at)} 129 + <td class="text-muted px-4 py-3 font-mono text-xs"> 130 + {formatShortDate(post.created_at)} 97 131 </td> 98 132 99 - <td class="px-4 py-3 font-mono text-xs text-bright text-center"> 133 + <td class="text-bright px-4 py-3 text-center font-mono text-xs"> 100 134 {post.like_count || 0} 101 135 </td> 102 136 103 - <td class="px-4 py-3 font-mono text-xs text-bright text-center"> 137 + <td class="text-bright px-4 py-3 text-center font-mono text-xs"> 104 138 {post.repost_count || 0} 105 139 </td> 106 140 107 - <td class="px-4 py-3 font-mono text-xs text-bright text-center"> 141 + <td class="text-bright px-4 py-3 text-center font-mono text-xs"> 108 142 {post.reply_count || 0} 109 143 </td> 110 144 111 145 <td class="px-4 py-3"> 112 146 <span 113 - class="font-sans text-xs px-2 py-0.5 rounded-full {post.source === 'saved' 147 + class="rounded-full px-2 py-0.5 font-sans text-xs {post.source === 'saved' 114 148 ? 'bg-primary/20 text-primary' 115 149 : 'bg-secondary/20 text-secondary'}"> 116 150 {post.source} ··· 121 155 <tr> 122 156 <td colspan={columns.length} class="px-4 py-12 text-center"> 123 157 <p class="font-sans text-muted">No posts found</p> 124 - <p class="font-mono text-xs text-[#333] mt-2">Try searching or refreshing your data</p> 158 + <p class="mt-2 font-mono text-xs text-[#333]">Try searching or refreshing your data</p> 125 159 </td> 126 160 </tr> 127 161 {/each} 128 162 </tbody> 163 + 164 + {#if posts.length > 0} 165 + <tfoot class="sticky bottom-0 z-10 bg-black/95 backdrop-blur"> 166 + <tr> 167 + <td colspan={columns.length} class="border-outline border-t px-4 py-3"> 168 + <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> 169 + <p class="text-muted font-mono text-xs tracking-[0.14em] uppercase"> 170 + Showing {pageStart}-{pageEnd} of {posts.length} 171 + </p> 172 + 173 + <div class="flex flex-wrap items-center gap-2"> 174 + <button 175 + type="button" 176 + class="border-outline text-muted hover:text-bright rounded-full border px-3 py-1.5 font-mono text-xs transition-colors disabled:opacity-40" 177 + onclick={() => (currentPage = Math.max(1, currentPage - 1))} 178 + disabled={currentPage === 1}> 179 + Prev 180 + </button> 181 + 182 + {#each visiblePages as page} 183 + <button 184 + type="button" 185 + class="min-w-9 rounded-full border px-3 py-1.5 font-mono text-xs transition-colors {page === 186 + currentPage 187 + ? 'border-primary bg-primary/15 text-primary' 188 + : 'border-outline text-muted hover:text-bright'}" 189 + onclick={() => (currentPage = page)}> 190 + {page} 191 + </button> 192 + {/each} 193 + 194 + <button 195 + type="button" 196 + class="border-outline text-muted hover:text-bright rounded-full border px-3 py-1.5 font-mono text-xs transition-colors disabled:opacity-40" 197 + onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))} 198 + disabled={currentPage === totalPages}> 199 + Next 200 + </button> 201 + </div> 202 + </div> 203 + </td> 204 + </tr> 205 + </tfoot> 206 + {/if} 129 207 </table> 130 208 </div> 131 209 </div>
+12 -45
frontend/src/lib/components/EmptyState.svelte
··· 1 1 <script lang="ts"> 2 2 import { fade } from "svelte/transition"; 3 3 4 - interface Props { 5 - onRefresh: () => void; 6 - } 7 - 8 - let { onRefresh }: Props = $props(); 4 + let { onRefresh }: { onRefresh: () => void } = $props(); 9 5 </script> 10 6 11 - <div 12 - transition:fade={{ duration: 300 }} 13 - class="flex flex-col items-center justify-center h-full text-center p-8" 14 - > 7 + <div transition:fade={{ duration: 300 }} class="flex h-full flex-col items-center justify-center p-8 text-center"> 15 8 <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> 9 + <span class="text-muted mx-auto mb-4 flex items-center"> 10 + <i class="i-ri-inbox-archive-fill text-2xl"></i> 11 + </span> 30 12 </div> 31 13 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. 14 + <h2 class="text-bright mb-2 font-serif text-2xl">No posts indexed</h2> 15 + 16 + <p class="text-muted mb-6 max-w-md font-sans"> 17 + Your Bluesky bookmarks and likes haven't been indexed yet. Click the button below to start indexing your posts. 37 18 </p> 38 19 39 20 <button 40 21 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 22 + class="bg-surface border-outline hover:bg-outline text-bright flex items-center gap-2 rounded-lg border px-6 py-3 font-sans transition-colors"> 23 + <i class="i-ri-refresh-line h-5 w-5"></i> 24 + <span>Refresh Data</span> 58 25 </button> 59 26 </div>
+31 -27
frontend/src/lib/components/LogViewer.svelte
··· 1 1 <script lang="ts"> 2 + import { Clear, GetEntries } from "../../../wailsjs/go/main/LogService"; 2 3 import { EventsOn } from "../../../wailsjs/runtime/runtime"; 3 4 import { onMount } from "svelte"; 4 5 5 6 type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; 6 7 7 - type LogEntry = { 8 - level: LogLevel; 9 - message: string; 10 - timestamp: string; 11 - }; 8 + type LogEntry = { level: LogLevel; message: string; timestamp: string }; 12 9 13 - type Props = { 14 - visible: boolean; 15 - }; 10 + type Props = { visible: boolean }; 16 11 17 12 let { visible }: Props = $props(); 18 13 ··· 53 48 54 49 function formatTimestamp(timestamp: string): string { 55 50 const date = new Date(timestamp); 56 - return date.toLocaleTimeString("en-US", { 57 - hour12: false, 58 - hour: "2-digit", 59 - minute: "2-digit", 60 - second: "2-digit", 61 - }); 51 + return date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); 62 52 } 63 53 64 54 function scrollToBottom() { ··· 77 67 78 68 function clearLogs() { 79 69 logs = []; 70 + void Clear(); 80 71 } 81 72 82 73 function filteredLogs() { ··· 87 78 } 88 79 89 80 onMount(() => { 81 + GetEntries() 82 + .then((entries) => { 83 + logs = entries.map((entry) => ({ 84 + level: entry.level as LogLevel, 85 + message: entry.message, 86 + timestamp: entry.timestamp, 87 + })); 88 + setTimeout(scrollToBottom, 0); 89 + }) 90 + .catch((err) => { 91 + console.error("Failed to load logs:", err); 92 + }); 93 + 90 94 EventsOn("log:line", (entry: LogEntry) => { 91 95 logs = [...logs, entry]; 92 96 ··· 110 114 </script> 111 115 112 116 {#if visible} 113 - <div class="border-t border-outline bg-black flex flex-col"> 117 + <div class="border-outline flex flex-col border-t bg-black"> 114 118 <!-- Header --> 115 - <div class="flex items-center justify-between px-4 py-2 bg-surface border-b border-outline"> 119 + <div class="bg-surface border-outline flex items-center justify-between border-b px-4 py-2"> 116 120 <div class="flex items-center gap-2"> 117 - <span class="font-mono text-sm text-bright">Logs</span> 118 - <span class="font-mono text-xs text-muted">({logs.length})</span> 121 + <span class="text-bright font-mono text-sm">Logs</span> 122 + <span class="text-muted font-mono text-xs">({logs.length})</span> 119 123 </div> 120 124 121 125 <div class="flex items-center gap-2"> 122 126 <!-- Level Filter Buttons --> 123 - <div class="flex items-center gap-1 mr-4"> 127 + <div class="mr-4 flex items-center gap-1"> 124 128 {#each ["ALL", ...levels] as level} 125 129 <button 126 130 onclick={() => setFilterLevel(level as LogLevel | "ALL")} 127 - class="font-mono text-xs px-2 py-1 rounded transition-colors {filterLevel === level 131 + class="rounded px-2 py-1 font-mono text-xs transition-colors {filterLevel === level 128 132 ? getLevelBgColor(level) + ' text-white' 129 - : 'bg-black text-muted hover:text-bright'}"> 133 + : 'text-muted hover:text-bright bg-black'}"> 130 134 {level} 131 135 </button> 132 136 {/each} ··· 135 139 <!-- Scroll Lock Toggle --> 136 140 <button 137 141 onclick={toggleScrollLock} 138 - class="font-mono text-xs px-2 py-1 rounded transition-colors {scrollLock 142 + class="rounded px-2 py-1 font-mono text-xs transition-colors {scrollLock 139 143 ? 'bg-yellow-600 text-white' 140 - : 'bg-black text-muted hover:text-bright'}" 144 + : 'text-muted hover:text-bright bg-black'}" 141 145 title={scrollLock ? "Scroll locked" : "Auto-scroll enabled"}> 142 146 {#if scrollLock} 143 147 <span class="flex items-center"> ··· 153 157 <!-- Clear Button --> 154 158 <button 155 159 onclick={clearLogs} 156 - class="font-mono text-xs px-2 py-1 rounded bg-black text-muted hover:text-red-400 transition-colors"> 160 + class="text-muted rounded bg-black px-2 py-1 font-mono text-xs transition-colors hover:text-red-400"> 157 161 Clear 158 162 </button> 159 163 </div> ··· 162 166 <!-- Log Container --> 163 167 <div 164 168 bind:this={logContainer} 165 - class="flex-1 overflow-y-auto p-2 font-mono text-xs space-y-0.5" 169 + class="flex-1 space-y-0.5 overflow-y-auto p-2 font-mono text-xs" 166 170 style="max-height: 200px;"> 167 171 {#each filteredLogs() as log} 168 - <div class="flex items-start gap-2 hover:bg-white/5 px-1 rounded"> 172 + <div class="flex items-start gap-2 rounded px-1 hover:bg-white/5"> 169 173 <span class="text-muted shrink-0">{formatTimestamp(log.timestamp)}</span> 170 - <span class="{getLevelColor(log.level)} shrink-0 w-12">[{log.level}]</span> 174 + <span class="{getLevelColor(log.level)} w-12 shrink-0">[{log.level}]</span> 171 175 <span class="text-bright break-all">{log.message}</span> 172 176 </div> 173 177 {:else}
+98
frontend/src/lib/components/PostDetailPanel.svelte
··· 1 + <script lang="ts"> 2 + import { BrowserOpenURL } from "../../../wailsjs/runtime/runtime"; 3 + import type { main } from "../../../wailsjs/go/models"; 4 + import PostText from "./PostText.svelte"; 5 + import { formatLongDateTime } from "../date"; 6 + 7 + interface Props { 8 + post: main.SearchResult; 9 + onClose: () => void; 10 + } 11 + 12 + let { post, onClose }: Props = $props(); 13 + 14 + function buildPostURL(uri: string): string { 15 + const match = uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/); 16 + if (match) { 17 + return `https://bsky.app/profile/${match[1]}/post/${match[2]}`; 18 + } 19 + return uri; 20 + } 21 + 22 + function openInBrowser() { 23 + BrowserOpenURL(buildPostURL(post.uri)); 24 + } 25 + </script> 26 + 27 + <aside 28 + class="border-outline bg-surface/95 flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[1.25rem] border shadow-[0_24px_80px_rgba(0,0,0,0.45)] backdrop-blur xl:w-100 xl:min-w-100"> 29 + <header class="border-outline border-b bg-black/80 px-5 py-4"> 30 + <div class="flex items-start justify-between gap-4"> 31 + <div class="min-w-0"> 32 + <p class="text-muted font-mono text-[11px] tracking-[0.3em] uppercase">Reading Pane</p> 33 + <h2 class="text-bright mt-2 truncate font-serif text-2xl">@{post.author_handle}</h2> 34 + <p class="text-muted mt-1 font-mono text-xs">{formatLongDateTime(post.created_at)}</p> 35 + </div> 36 + 37 + <button 38 + type="button" 39 + onclick={onClose} 40 + class="border-outline bg-surface text-muted hover:text-bright rounded-full border px-3 py-1.5 font-mono text-xs transition-colors"> 41 + Close 42 + </button> 43 + </div> 44 + </header> 45 + 46 + <div class="flex-1 overflow-y-auto px-5 py-5"> 47 + <div class="border-outline rounded-2xl border bg-black/60 p-5"> 48 + <div class="mb-5 flex flex-wrap gap-2"> 49 + <span 50 + class="bg-primary/15 text-primary rounded-full px-3 py-1 font-mono text-[11px] tracking-[0.18em] uppercase"> 51 + {post.source} 52 + </span> 53 + <span 54 + class="border-outline text-muted flex items-center gap-1 rounded-full border px-3 py-1 font-mono text-[11px]"> 55 + <i class="i-ri-heart-line"></i> 56 + <span>{post.like_count || 0}</span> 57 + </span> 58 + <span class="border-outline text-muted flex items-center rounded-full border px-3 py-1 font-mono text-[11px]"> 59 + <i class="i-ri-repeat-line"></i> 60 + <span>{post.repost_count || 0}</span> 61 + </span> 62 + <span class="border-outline text-muted flex items-center rounded-full border px-3 py-1 font-mono text-[11px]"> 63 + <i class="i-ri-message-2-line"></i> 64 + <span>{post.reply_count || 0}</span> 65 + </span> 66 + </div> 67 + 68 + <div class="space-y-4"> 69 + <p class="text-muted font-mono text-xs tracking-[0.22em] uppercase">Post</p> 70 + <div class="text-bright font-mono text-sm leading-7 text-pretty"> 71 + <PostText text={post.text} facetsJson={post.facets} /> 72 + </div> 73 + </div> 74 + </div> 75 + 76 + <dl class="border-outline bg-surface/80 mt-5 grid gap-4 rounded-2xl border p-4"> 77 + <div> 78 + <dt class="text-muted font-mono text-[11px] tracking-[0.2em] uppercase">Post URI</dt> 79 + <dd class="text-bright mt-1 font-mono text-xs break-all">{post.uri}</dd> 80 + </div> 81 + <div> 82 + <dt class="text-muted font-mono text-[11px] tracking-[0.2em] uppercase">Indexed</dt> 83 + <dd class="text-bright mt-1 font-mono text-xs">{formatLongDateTime(post.indexed_at)}</dd> 84 + </div> 85 + </dl> 86 + </div> 87 + 88 + <footer class="border-outline border-t bg-black/75 px-5 py-4"> 89 + <button 90 + type="button" 91 + onclick={openInBrowser} 92 + class="border-outline bg-surface text-bright hover:bg-outline flex w-full items-center rounded-xl border px-4 py-3 font-sans text-sm transition-colors"> 93 + <span>Open on BlueSky</span> 94 + <i class="i-ri-blue-sky-fill m-2"></i> 95 + <i class="i-ri-external-link-line"></i> 96 + </button> 97 + </footer> 98 + </aside>
+2 -7
frontend/src/lib/components/PostText.svelte
··· 1 1 <script lang="ts"> 2 2 import { parseFacets, renderFacets, truncateRenderedFacets } from "../facets"; 3 3 4 - type Props = { 5 - text: string; 6 - facetsJson?: string; 7 - maxLength?: number; 8 - class?: string; 9 - }; 4 + type Props = { text: string; facetsJson?: string; maxLength?: number; class?: string }; 10 5 11 6 let { text, facetsJson = "", maxLength = 0, class: className = "" }: Props = $props(); 12 7 ··· 53 48 {facet.text} 54 49 </a> 55 50 {:else} 56 - <span>{facet.text}</span> 51 + <span class="font-sans">{facet.text}</span> 57 52 {/if} 58 53 {/each} 59 54 </span>
+13 -10
frontend/src/lib/components/ProgressBar.svelte
··· 2 2 import { slide } from "svelte/transition"; 3 3 import type { IndexStats } from "../types"; 4 4 5 - const { isIndexing, indexStats } = $props<{ isIndexing: boolean; indexStats: IndexStats }>(); 5 + type Props = { isIndexing: boolean; indexStats: IndexStats }; 6 + 7 + const { isIndexing, indexStats }: Props = $props(); 6 8 </script> 7 9 8 - <div class="border-t border-outline bg-surface px-6 py-3" transition:slide={{ duration: 300 }}> 9 - <div class="flex items-center justify-between mb-2"> 10 - <span class="font-sans text-sm text-muted"> 10 + <div class="border-outline bg-surface border-t px-6 py-3" transition:slide={{ duration: 300 }}> 11 + <div class="mb-2 flex items-center justify-between"> 12 + <span class="text-muted font-sans text-sm"> 11 13 {#if isIndexing} 12 14 <span class="animate-pulse">Indexing...</span> 13 15 {:else} ··· 17 19 </span> 18 20 {/if} 19 21 </span> 20 - <span class="font-mono text-xs text-muted"> 22 + <span class="text-muted font-mono text-xs"> 21 23 {indexStats.inserted} inserted / {indexStats.fetched} fetched 22 24 {#if indexStats.errors > 0} 23 25 <span class="text-red-500">({indexStats.errors} errors)</span> ··· 25 27 </span> 26 28 </div> 27 29 28 - <div class="w-full h-1 bg-black rounded-full overflow-hidden"> 29 - <div 30 - class="h-full bg-[#333] transition-all duration-300 ease-out" 31 - style="width: {indexStats.fetched > 0 ? (indexStats.inserted / indexStats.fetched) * 100 : 0}%"> 32 - </div> 30 + <div class="h-1 w-full overflow-hidden rounded-full bg-black"> 31 + {#if isIndexing} 32 + <div class="h-full w-1/3 animate-[progress-indeterminate_1.2s_ease-in-out_infinite] bg-[#555]"></div> 33 + {:else} 34 + <div class="h-full bg-[#333] transition-all duration-300 ease-out" style="width: 100%"></div> 35 + {/if} 33 36 </div> 34 37 </div>
+40
frontend/src/lib/date.ts
··· 1 + export function parseDateValue(value: unknown): Date | null { 2 + if (!value) { 3 + return null; 4 + } 5 + 6 + if (value instanceof Date) { 7 + return Number.isNaN(value.getTime()) || value.getUTCFullYear() <= 1 ? null : value; 8 + } 9 + 10 + if (typeof value === "string" || typeof value === "number") { 11 + const parsed = new Date(value); 12 + return Number.isNaN(parsed.getTime()) || parsed.getUTCFullYear() <= 1 ? null : parsed; 13 + } 14 + 15 + return null; 16 + } 17 + 18 + export function formatShortDate(value: unknown): string { 19 + const date = parseDateValue(value); 20 + if (!date) { 21 + return "-"; 22 + } 23 + 24 + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); 25 + } 26 + 27 + export function formatLongDateTime(value: unknown): string { 28 + const date = parseDateValue(value); 29 + if (!date) { 30 + return "Unknown date"; 31 + } 32 + 33 + return date.toLocaleString("en-US", { 34 + month: "short", 35 + day: "numeric", 36 + year: "numeric", 37 + hour: "numeric", 38 + minute: "2-digit", 39 + }); 40 + }
+2
frontend/wailsjs/go/main/AuthService.d.ts
··· 6 6 7 7 export function Login(arg1:string):Promise<void>; 8 8 9 + export function Logout():Promise<void>; 10 + 9 11 export function RefreshSession():Promise<void>; 10 12 11 13 export function Whoami(arg1:boolean):Promise<main.Auth>;
+4
frontend/wailsjs/go/main/AuthService.js
··· 10 10 return window['go']['main']['AuthService']['Login'](arg1); 11 11 } 12 12 13 + export function Logout() { 14 + return window['go']['main']['AuthService']['Logout'](); 15 + } 16 + 13 17 export function RefreshSession() { 14 18 return window['go']['main']['AuthService']['RefreshSession'](); 15 19 }
+22
frontend/wailsjs/go/main/LogService.d.ts
··· 1 + // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 2 + // This file is automatically generated. DO NOT EDIT 3 + import {main} from '../models'; 4 + import {io} from '../models'; 5 + 6 + export function Clear():Promise<void>; 7 + 8 + export function Close():Promise<void>; 9 + 10 + export function GetEntries():Promise<Array<main.LogEntry>>; 11 + 12 + export function GetEntriesByLevel(arg1:string):Promise<Array<main.LogEntry>>; 13 + 14 + export function GetLevel():Promise<string>; 15 + 16 + export function GetMultiWriter():Promise<io.Writer>; 17 + 18 + export function GetWriter():Promise<io.Writer>; 19 + 20 + export function Initialize():Promise<void>; 21 + 22 + export function SetLevel(arg1:string):Promise<void>;
+39
frontend/wailsjs/go/main/LogService.js
··· 1 + // @ts-check 2 + // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 3 + // This file is automatically generated. DO NOT EDIT 4 + 5 + export function Clear() { 6 + return window['go']['main']['LogService']['Clear'](); 7 + } 8 + 9 + export function Close() { 10 + return window['go']['main']['LogService']['Close'](); 11 + } 12 + 13 + export function GetEntries() { 14 + return window['go']['main']['LogService']['GetEntries'](); 15 + } 16 + 17 + export function GetEntriesByLevel(arg1) { 18 + return window['go']['main']['LogService']['GetEntriesByLevel'](arg1); 19 + } 20 + 21 + export function GetLevel() { 22 + return window['go']['main']['LogService']['GetLevel'](); 23 + } 24 + 25 + export function GetMultiWriter() { 26 + return window['go']['main']['LogService']['GetMultiWriter'](); 27 + } 28 + 29 + export function GetWriter() { 30 + return window['go']['main']['LogService']['GetWriter'](); 31 + } 32 + 33 + export function Initialize() { 34 + return window['go']['main']['LogService']['Initialize'](); 35 + } 36 + 37 + export function SetLevel(arg1) { 38 + return window['go']['main']['LogService']['SetLevel'](arg1); 39 + }
+35
frontend/wailsjs/go/models.ts
··· 55 55 return a; 56 56 } 57 57 } 58 + export class LogEntry { 59 + level: string; 60 + message: string; 61 + // Go type: time 62 + timestamp: any; 63 + 64 + static createFrom(source: any = {}) { 65 + return new LogEntry(source); 66 + } 67 + 68 + constructor(source: any = {}) { 69 + if ('string' === typeof source) source = JSON.parse(source); 70 + this.level = source["level"]; 71 + this.message = source["message"]; 72 + this.timestamp = this.convertValues(source["timestamp"], null); 73 + } 74 + 75 + convertValues(a: any, classs: any, asMap: boolean = false): any { 76 + if (!a) { 77 + return a; 78 + } 79 + if (a.slice && a.map) { 80 + return (a as any[]).map(elem => this.convertValues(elem, classs)); 81 + } else if ("object" === typeof a) { 82 + if (asMap) { 83 + for (const key of Object.keys(a)) { 84 + a[key] = new classs(a[key]); 85 + } 86 + return a; 87 + } 88 + return new classs(a); 89 + } 90 + return a; 91 + } 92 + } 58 93 export class SearchResult { 59 94 uri: string; 60 95 cid: string;
+46 -5
index_service.go
··· 66 66 defer s.indexing.Store(false) 67 67 68 68 start := time.Now() 69 + LogInfof("index refresh started: limit=%d", limit) 69 70 70 71 s.statsMu.Lock() 71 72 s.stats = IndexStats{} ··· 108 109 Elapsed: time.Since(start), 109 110 } 110 111 112 + LogInfof("index refresh completed: total=%d errors=%d elapsed=%s", result.Total, result.Errors, result.Elapsed) 111 113 s.emitEvent("index:done", result) 112 114 return nil 113 115 } ··· 198 200 199 201 if result.Post != nil { 200 202 batch = append(batch, result.Post) 201 - s.updateProgress(1, 0, 0) 202 203 203 204 if len(batch) >= batchSize { 204 205 flushBatch() ··· 217 218 } 218 219 219 220 // fetchBookmarks writes bookmarks to the provided channel in batches 220 - func (c *BlueskyClient) fetchBookmarks(maxPosts int, ch chan<- *PostResult, _ *IndexService) { 221 + func (c *BlueskyClient) fetchBookmarks(maxPosts int, ch chan<- *PostResult, svc *IndexService) { 221 222 ctx := context.Background() 222 223 apiClient := c.session.APIClient() 223 224 var cursor string 225 + seenCursors := make(map[string]struct{}) 224 226 batchSize := int64(100) 225 227 count := 0 226 228 227 229 for { 230 + LogInfof("fetching bookmarks page: cursor=%q", cursor) 228 231 resp, err := bsky.BookmarkGetBookmarks(ctx, apiClient, cursor, batchSize) 229 232 if err != nil { 230 233 ch <- &PostResult{Error: fmt.Errorf("failed to fetch bookmarks: %w", err)} 231 234 return 232 235 } 236 + nextCursor := "" 237 + if resp.Cursor != nil { 238 + nextCursor = *resp.Cursor 239 + } 240 + LogInfof("fetched bookmarks page: items=%d next_cursor=%q", len(resp.Bookmarks), nextCursor) 233 241 234 242 for _, bookmark := range resp.Bookmarks { 235 243 if bookmark.Item == nil { ··· 237 245 } 238 246 239 247 if bookmark.Item.FeedDefs_PostView != nil { 248 + svc.updateProgress(1, 0, 0) 240 249 pv := bookmark.Item.FeedDefs_PostView 241 250 242 251 exists, err := PostExists(pv.Uri) ··· 260 269 } 261 270 262 271 if resp.Cursor == nil || *resp.Cursor == "" { 272 + LogInfof("bookmark fetch complete: processed=%d", count) 263 273 break 264 274 } 265 - cursor = *resp.Cursor 275 + 276 + nextCursor = *resp.Cursor 277 + if nextCursor == cursor { 278 + LogWarnf("stopping bookmark pagination because cursor repeated: %s", nextCursor) 279 + break 280 + } 281 + if _, seen := seenCursors[nextCursor]; seen { 282 + LogWarnf("stopping bookmark pagination because cursor loop detected: %s", nextCursor) 283 + break 284 + } 285 + seenCursors[nextCursor] = struct{}{} 286 + cursor = nextCursor 266 287 } 267 288 } 268 289 269 290 // fetchLikes writes likes to the provided channel in batches 270 - func (c *BlueskyClient) fetchLikes(maxPosts int, ch chan<- *PostResult, _ *IndexService) { 291 + func (c *BlueskyClient) fetchLikes(maxPosts int, ch chan<- *PostResult, svc *IndexService) { 271 292 ctx := context.Background() 272 293 apiClient := c.session.APIClient() 273 294 var cursor string 295 + seenCursors := make(map[string]struct{}) 274 296 batchSize := int64(100) 275 297 count := 0 276 298 277 299 for { 300 + LogInfof("fetching likes page: cursor=%q", cursor) 278 301 resp, err := bsky.FeedGetActorLikes(ctx, apiClient, c.auth.DID, cursor, batchSize) 279 302 if err != nil { 280 303 ch <- &PostResult{Error: fmt.Errorf("failed to fetch likes: %w", err)} 281 304 return 282 305 } 306 + nextCursor := "" 307 + if resp.Cursor != nil { 308 + nextCursor = *resp.Cursor 309 + } 310 + LogInfof("fetched likes page: items=%d next_cursor=%q", len(resp.Feed), nextCursor) 283 311 284 312 for _, feedView := range resp.Feed { 285 313 if feedView.Post != nil { 314 + svc.updateProgress(1, 0, 0) 286 315 pv := feedView.Post 287 316 288 317 exists, err := PostExists(pv.Uri) ··· 306 335 } 307 336 308 337 if resp.Cursor == nil || *resp.Cursor == "" { 338 + LogInfof("likes fetch complete: processed=%d", count) 309 339 break 310 340 } 311 - cursor = *resp.Cursor 341 + 342 + nextCursor = *resp.Cursor 343 + if nextCursor == cursor { 344 + LogWarnf("stopping likes pagination because cursor repeated: %s", nextCursor) 345 + break 346 + } 347 + if _, seen := seenCursors[nextCursor]; seen { 348 + LogWarnf("stopping likes pagination because cursor loop detected: %s", nextCursor) 349 + break 350 + } 351 + seenCursors[nextCursor] = struct{}{} 352 + cursor = nextCursor 312 353 } 313 354 } 314 355
+2 -1
main.go
··· 22 22 BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 1}, 23 23 OnStartup: app.startup, 24 24 OnShutdown: app.shutdown, 25 - Bind: []any{app, app.authService, app.indexService, app.searchService}, 25 + Bind: []any{app, app.authService, app.indexService, app.searchService, app.logService}, 26 26 }) 27 27 28 28 if err != nil { 29 + LogErrorf("Application error: %v", err) 29 30 runtime.LogErrorf(app.ctx, "Application error: %v", err) 30 31 } 31 32 }