search for standard sites pub-search.waow.tech
search zig blog atproto

feat: theme toggle + cover image thumbnails

- 3-way theme toggle (dark/light/system) with localStorage persistence
and flash prevention on both search and dashboard pages
- extract cover image blob CID from document records (coverImage field
for pckt/offprint/greengale, first image block fallback for leaflet)
- add cover_image column to documents table, pass through indexer/search
- render 32x32 thumbnails in search results via bsky CDN, graceful
fallback when image unavailable
- convert all hardcoded colors to CSS custom properties

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+483 -151
+3
backend/src/db/schema.zig
··· 229 \\AND did IN (SELECT did FROM publications WHERE base_path LIKE 'greengale.app/%') 230 , &.{}) catch {}; 231 232 // DiskANN vector index (documents_embedding_idx) is managed via scripts/rebuild-documents-table 233 // DO NOT add CREATE INDEX here — it hangs on startup when the index already exists 234
··· 229 \\AND did IN (SELECT did FROM publications WHERE base_path LIKE 'greengale.app/%') 230 , &.{}) catch {}; 231 232 + // cover_image: blob CID for document cover image (used for thumbnails in search results) 233 + client.exec("ALTER TABLE documents ADD COLUMN cover_image TEXT", &.{}) catch {}; 234 + 235 // DiskANN vector index (documents_embedding_idx) is managed via scripts/rebuild-documents-table 236 // DO NOT add CREATE INDEX here — it hangs on startup when the index already exists 237
+89
backend/src/ingest/extractor.zig
··· 42 source_collection: []const u8, 43 path: ?[]const u8, // URL path from record (e.g., "/001" for zat.dev) 44 content_type: ?[]const u8, // content.$type (e.g., "pub.leaflet.content") for platform detection 45 46 pub fn deinit(self: *ExtractedDocument) void { 47 self.allocator.free(self.content); ··· 100 // extract content.$type for platform detection (e.g., "pub.leaflet.content") 101 const content_type = zat.json.getString(record_val, "content.$type"); 102 103 // extract tags - allocate owned slice 104 const tags = try extractTags(allocator, record_val); 105 errdefer allocator.free(tags); ··· 107 // extract content - try textContent first (standard.site), then parse blocks 108 const content = try extractContent(allocator, record_val); 109 110 return .{ 111 .allocator = allocator, 112 .title = title, ··· 118 .source_collection = collection, 119 .path = path, 120 .content_type = content_type, 121 }; 122 } 123 ··· 249 } 250 } 251 252 // --- tests --- 253 254 test "Platform.fromCollection: leaflet" { ··· 297 try std.testing.expectEqualStrings("Hello world", doc.content); 298 // content_type should be extracted for platform detection (custom domain support) 299 try std.testing.expectEqualStrings("pub.leaflet.content", doc.content_type.?); 300 } 301 302 test "extractDocument: com.whtwnd.blog.entry (whitewind)" {
··· 42 source_collection: []const u8, 43 path: ?[]const u8, // URL path from record (e.g., "/001" for zat.dev) 44 content_type: ?[]const u8, // content.$type (e.g., "pub.leaflet.content") for platform detection 45 + cover_image: ?[]const u8, // blob CID for cover image (e.g., "bafkrei...") 46 47 pub fn deinit(self: *ExtractedDocument) void { 48 self.allocator.free(self.content); ··· 101 // extract content.$type for platform detection (e.g., "pub.leaflet.content") 102 const content_type = zat.json.getString(record_val, "content.$type"); 103 104 + // extract cover image blob CID 105 + // try coverImage.ref.$link first (site.standard/pckt/offprint/greengale) 106 + const cover_image = zat.json.getString(record_val, "coverImage.ref.$link"); 107 + 108 // extract tags - allocate owned slice 109 const tags = try extractTags(allocator, record_val); 110 errdefer allocator.free(tags); ··· 112 // extract content - try textContent first (standard.site), then parse blocks 113 const content = try extractContent(allocator, record_val); 114 115 + // for leaflet documents without a coverImage, try first image block 116 + const final_cover_image = cover_image orelse extractFirstImageCid(record_val); 117 + 118 return .{ 119 .allocator = allocator, 120 .title = title, ··· 126 .source_collection = collection, 127 .path = path, 128 .content_type = content_type, 129 + .cover_image = final_cover_image, 130 }; 131 } 132 ··· 258 } 259 } 260 261 + /// Extract first image blob CID from leaflet-style page blocks. 262 + /// Searches pages -> blocks for pub.leaflet.blocks.image and returns image.ref.$link. 263 + fn extractFirstImageCid(record: json.Value) ?[]const u8 { 264 + // check for pages at top level or nested in content object 265 + const pages = zat.json.getArray(record, "pages") orelse 266 + zat.json.getArray(record, "content.pages") orelse 267 + return null; 268 + 269 + for (pages) |page| { 270 + if (page != .object) continue; 271 + const blocks_val = page.object.get("blocks") orelse continue; 272 + if (blocks_val != .array) continue; 273 + 274 + for (blocks_val.array.items) |wrapper| { 275 + if (wrapper != .object) continue; 276 + const block_val = wrapper.object.get("block") orelse continue; 277 + if (block_val != .object) continue; 278 + 279 + const type_val = block_val.object.get("$type") orelse continue; 280 + if (type_val != .string) continue; 281 + if (!mem.eql(u8, type_val.string, "pub.leaflet.blocks.image")) continue; 282 + 283 + // found an image block — extract image.ref.$link 284 + const image_val: json.Value = .{ .object = block_val.object }; 285 + if (zat.json.getString(image_val, "image.ref.$link")) |cid| { 286 + return cid; 287 + } 288 + } 289 + } 290 + return null; 291 + } 292 + 293 // --- tests --- 294 295 test "Platform.fromCollection: leaflet" { ··· 338 try std.testing.expectEqualStrings("Hello world", doc.content); 339 // content_type should be extracted for platform detection (custom domain support) 340 try std.testing.expectEqualStrings("pub.leaflet.content", doc.content_type.?); 341 + } 342 + 343 + test "extractDocument: site.standard.document with coverImage" { 344 + const allocator = std.testing.allocator; 345 + 346 + const test_json = 347 + \\{"title":"Cover Test","textContent":"body text","coverImage":{"$type":"blob","ref":{"$link":"bafkreicover123"},"mimeType":"image/jpeg","size":1234}} 348 + ; 349 + 350 + const parsed = try json.parseFromSlice(json.Value, allocator, test_json, .{}); 351 + defer parsed.deinit(); 352 + 353 + var doc = try extractDocument(allocator, parsed.value.object, "site.standard.document"); 354 + defer doc.deinit(); 355 + 356 + try std.testing.expectEqualStrings("bafkreicover123", doc.cover_image.?); 357 + } 358 + 359 + test "extractDocument: leaflet with image block fallback" { 360 + const allocator = std.testing.allocator; 361 + 362 + const test_json = 363 + \\{"title":"Image Post","content":{"$type":"pub.leaflet.content","pages":[{"id":"page1","$type":"pub.leaflet.pages.linearDocument","blocks":[{"$type":"pub.leaflet.pages.linearDocument#block","block":{"$type":"pub.leaflet.blocks.text","plaintext":"Hello"}},{"$type":"pub.leaflet.pages.linearDocument#block","block":{"$type":"pub.leaflet.blocks.image","image":{"$type":"blob","ref":{"$link":"bafkreileafimg456"},"mimeType":"image/png","size":5678}}}]}]}} 364 + ; 365 + 366 + const parsed = try json.parseFromSlice(json.Value, allocator, test_json, .{}); 367 + defer parsed.deinit(); 368 + 369 + var doc = try extractDocument(allocator, parsed.value.object, "site.standard.document"); 370 + defer doc.deinit(); 371 + 372 + try std.testing.expectEqualStrings("bafkreileafimg456", doc.cover_image.?); 373 + } 374 + 375 + test "extractDocument: no cover image" { 376 + const allocator = std.testing.allocator; 377 + 378 + const test_json = 379 + \\{"title":"No Image","textContent":"just text"} 380 + ; 381 + 382 + const parsed = try json.parseFromSlice(json.Value, allocator, test_json, .{}); 383 + defer parsed.deinit(); 384 + 385 + var doc = try extractDocument(allocator, parsed.value.object, "site.standard.document"); 386 + defer doc.deinit(); 387 + 388 + try std.testing.expect(doc.cover_image == null); 389 } 390 391 test "extractDocument: com.whtwnd.blog.entry (whitewind)" {
+5 -3
backend/src/ingest/indexer.zig
··· 26 source_collection: []const u8, 27 path: ?[]const u8, 28 content_type: ?[]const u8, 29 ) !void { 30 const c = db.getClient() orelse return error.NotInitialized; 31 ··· 149 // indexed_at uses strftime to record when this row was inserted/updated in Turso 150 // (created_at is the document's publication date, which can be old for resynced docs) 151 try c.exec( 152 - \\INSERT INTO documents (uri, did, rkey, title, content, created_at, publication_uri, platform, source_collection, path, base_path, has_publication, content_hash, indexed_at) 153 - \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%S', 'now')) 154 \\ON CONFLICT(uri) DO UPDATE SET 155 \\ did = excluded.did, 156 \\ rkey = excluded.rkey, ··· 164 \\ base_path = excluded.base_path, 165 \\ has_publication = excluded.has_publication, 166 \\ content_hash = excluded.content_hash, 167 \\ indexed_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), 168 \\ embedded_at = documents.embedded_at 169 , 170 - &.{ uri, did, rkey, title, content, created_at orelse "", pub_uri, actual_platform, source_collection, path orelse "", base_path, has_pub, &content_hash }, 171 ); 172 173 // update FTS index
··· 26 source_collection: []const u8, 27 path: ?[]const u8, 28 content_type: ?[]const u8, 29 + cover_image: ?[]const u8, 30 ) !void { 31 const c = db.getClient() orelse return error.NotInitialized; 32 ··· 150 // indexed_at uses strftime to record when this row was inserted/updated in Turso 151 // (created_at is the document's publication date, which can be old for resynced docs) 152 try c.exec( 153 + \\INSERT INTO documents (uri, did, rkey, title, content, created_at, publication_uri, platform, source_collection, path, base_path, has_publication, content_hash, cover_image, indexed_at) 154 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%S', 'now')) 155 \\ON CONFLICT(uri) DO UPDATE SET 156 \\ did = excluded.did, 157 \\ rkey = excluded.rkey, ··· 165 \\ base_path = excluded.base_path, 166 \\ has_publication = excluded.has_publication, 167 \\ content_hash = excluded.content_hash, 168 + \\ cover_image = excluded.cover_image, 169 \\ indexed_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), 170 \\ embedded_at = documents.embedded_at 171 , 172 + &.{ uri, did, rkey, title, content, created_at orelse "", pub_uri, actual_platform, source_collection, path orelse "", base_path, has_pub, &content_hash, cover_image orelse "" }, 173 ); 174 175 // update FTS index
+1
backend/src/ingest/tap.zig
··· 476 doc.source_collection, 477 doc.path, 478 doc.content_type, 479 ); 480 logfire.counter("tap.documents_indexed", 1); 481 }
··· 476 doc.source_collection, 477 doc.path, 478 doc.content_type, 479 + doc.cover_image, 480 ); 481 logfire.counter("tap.documents_indexed", 1); 482 }
+40 -18
backend/src/search.zig
··· 32 platform: []const u8, 33 path: []const u8 = "", // URL path from record (e.g., "/001") 34 source: []const u8 = "", 35 }; 36 37 /// Document search result (internal) ··· 46 hasPublication: bool, 47 platform: []const u8, 48 path: []const u8, 49 50 fn fromRow(row: db.Row) Doc { 51 return .{ ··· 59 .hasPublication = row.int(7) != 0, 60 .platform = row.text(8), 61 .path = row.text(9), 62 }; 63 } 64 ··· 74 .hasPublication = row.int(7) != 0, 75 .platform = row.text(8), 76 .path = row.text(9), 77 }; 78 } 79 ··· 89 .basePath = self.basePath, 90 .platform = self.platform, 91 .path = self.path, 92 }; 93 } 94 }; ··· 96 const DocsByTag = zql.Query( 97 \\SELECT d.uri, d.did, d.title, '' as snippet, 98 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 99 - \\ d.platform, COALESCE(d.path, '') as path 100 \\FROM documents d 101 \\JOIN document_tags dt ON d.uri = dt.document_uri 102 \\WHERE dt.tag = :tag ··· 107 \\SELECT f.uri, d.did, d.title, 108 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 109 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 110 - \\ d.platform, COALESCE(d.path, '') as path 111 \\FROM documents_fts f 112 \\JOIN documents d ON f.uri = d.uri 113 \\JOIN document_tags dt ON d.uri = dt.document_uri ··· 119 \\SELECT f.uri, d.did, d.title, 120 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 121 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 122 - \\ d.platform, COALESCE(d.path, '') as path 123 \\FROM documents_fts f 124 \\JOIN documents d ON f.uri = d.uri 125 \\WHERE documents_fts MATCH :query ··· 130 \\SELECT f.uri, d.did, d.title, 131 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 132 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 133 - \\ d.platform, COALESCE(d.path, '') as path 134 \\FROM documents_fts f 135 \\JOIN documents d ON f.uri = d.uri 136 \\WHERE documents_fts MATCH :query AND d.created_at >= :since ··· 141 \\SELECT f.uri, d.did, d.title, 142 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 143 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 144 - \\ d.platform, COALESCE(d.path, '') as path 145 \\FROM documents_fts f 146 \\JOIN documents d ON f.uri = d.uri 147 \\WHERE documents_fts MATCH :query AND d.platform = :platform ··· 152 \\SELECT f.uri, d.did, d.title, 153 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 154 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 155 - \\ d.platform, COALESCE(d.path, '') as path 156 \\FROM documents_fts f 157 \\JOIN documents d ON f.uri = d.uri 158 \\WHERE documents_fts MATCH :query AND d.platform = :platform AND d.created_at >= :since ··· 162 const DocsByTagAndPlatform = zql.Query( 163 \\SELECT d.uri, d.did, d.title, '' as snippet, 164 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 165 - \\ d.platform, COALESCE(d.path, '') as path 166 \\FROM documents d 167 \\JOIN document_tags dt ON d.uri = dt.document_uri 168 \\WHERE dt.tag = :tag AND d.platform = :platform ··· 173 \\SELECT f.uri, d.did, d.title, 174 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 175 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 176 - \\ d.platform, COALESCE(d.path, '') as path 177 \\FROM documents_fts f 178 \\JOIN documents d ON f.uri = d.uri 179 \\JOIN document_tags dt ON d.uri = dt.document_uri ··· 184 const DocsByPlatform = zql.Query( 185 \\SELECT d.uri, d.did, d.title, '' as snippet, 186 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 187 - \\ d.platform, COALESCE(d.path, '') as path 188 \\FROM documents d 189 \\WHERE d.platform = :platform 190 \\ORDER BY d.created_at DESC LIMIT 40 ··· 198 \\ d.created_at, d.rkey, 199 \\ p.base_path, 200 \\ 1 as has_publication, 201 - \\ d.platform, COALESCE(d.path, '') as path 202 \\FROM documents d 203 \\JOIN publications p ON d.publication_uri = p.uri 204 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 211 \\ d.created_at, d.rkey, 212 \\ p.base_path, 213 \\ 1 as has_publication, 214 - \\ d.platform, COALESCE(d.path, '') as path 215 \\FROM documents d 216 \\JOIN publications p ON d.publication_uri = p.uri 217 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 224 \\ d.created_at, d.rkey, 225 \\ p.base_path, 226 \\ 1 as has_publication, 227 - \\ d.platform, COALESCE(d.path, '') as path 228 \\FROM documents d 229 \\JOIN publications p ON d.publication_uri = p.uri 230 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 237 \\ d.created_at, d.rkey, 238 \\ p.base_path, 239 \\ 1 as has_publication, 240 - \\ d.platform, COALESCE(d.path, '') as path 241 \\FROM documents d 242 \\JOIN publications p ON d.publication_uri = p.uri 243 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 472 \\SELECT f.uri, d.did, d.title, 473 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 474 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 475 - \\ d.platform, COALESCE(d.path, '') as path 476 \\FROM documents_fts f 477 \\JOIN documents d ON f.uri = d.uri 478 \\WHERE documents_fts MATCH ? AND d.platform = ? ··· 495 var bp_rows = try local.query( 496 \\SELECT d.uri, d.did, d.title, '' as snippet, 497 \\ d.created_at, d.rkey, p.base_path, 498 - \\ 1 as has_publication, d.platform, COALESCE(d.path, '') as path 499 \\FROM documents d 500 \\JOIN publications p ON d.publication_uri = p.uri 501 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 519 \\SELECT f.uri, d.did, d.title, 520 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 521 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 522 - \\ d.platform, COALESCE(d.path, '') as path 523 \\FROM documents_fts f 524 \\JOIN documents d ON f.uri = d.uri 525 \\WHERE documents_fts MATCH ? ··· 549 var bp_rows = try local.query( 550 \\SELECT d.uri, d.did, d.title, '' as snippet, 551 \\ d.created_at, d.rkey, p.base_path, 552 - \\ 1 as has_publication, d.platform, COALESCE(d.path, '') as path 553 \\FROM documents d 554 \\JOIN publications p ON d.publication_uri = p.uri 555 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 952 } 953 try jw.objectField("snippet"); 954 try jw.write(snippet); 955 - inline for (.{ "rkey", "basePath", "platform", "path" }) |field| { 956 try jw.objectField(field); 957 try jw.write(jsonStr(obj, field)); 958 }
··· 32 platform: []const u8, 33 path: []const u8 = "", // URL path from record (e.g., "/001") 34 source: []const u8 = "", 35 + coverImage: []const u8 = "", 36 }; 37 38 /// Document search result (internal) ··· 47 hasPublication: bool, 48 platform: []const u8, 49 path: []const u8, 50 + coverImage: []const u8, 51 52 fn fromRow(row: db.Row) Doc { 53 return .{ ··· 61 .hasPublication = row.int(7) != 0, 62 .platform = row.text(8), 63 .path = row.text(9), 64 + .coverImage = row.text(10), 65 }; 66 } 67 ··· 77 .hasPublication = row.int(7) != 0, 78 .platform = row.text(8), 79 .path = row.text(9), 80 + .coverImage = row.text(10), 81 }; 82 } 83 ··· 93 .basePath = self.basePath, 94 .platform = self.platform, 95 .path = self.path, 96 + .coverImage = self.coverImage, 97 }; 98 } 99 }; ··· 101 const DocsByTag = zql.Query( 102 \\SELECT d.uri, d.did, d.title, '' as snippet, 103 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 104 + \\ d.platform, COALESCE(d.path, '') as path, 105 + \\ COALESCE(d.cover_image, '') as cover_image 106 \\FROM documents d 107 \\JOIN document_tags dt ON d.uri = dt.document_uri 108 \\WHERE dt.tag = :tag ··· 113 \\SELECT f.uri, d.did, d.title, 114 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 115 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 116 + \\ d.platform, COALESCE(d.path, '') as path, 117 + \\ COALESCE(d.cover_image, '') as cover_image 118 \\FROM documents_fts f 119 \\JOIN documents d ON f.uri = d.uri 120 \\JOIN document_tags dt ON d.uri = dt.document_uri ··· 126 \\SELECT f.uri, d.did, d.title, 127 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 128 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 129 + \\ d.platform, COALESCE(d.path, '') as path, 130 + \\ COALESCE(d.cover_image, '') as cover_image 131 \\FROM documents_fts f 132 \\JOIN documents d ON f.uri = d.uri 133 \\WHERE documents_fts MATCH :query ··· 138 \\SELECT f.uri, d.did, d.title, 139 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 140 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 141 + \\ d.platform, COALESCE(d.path, '') as path, 142 + \\ COALESCE(d.cover_image, '') as cover_image 143 \\FROM documents_fts f 144 \\JOIN documents d ON f.uri = d.uri 145 \\WHERE documents_fts MATCH :query AND d.created_at >= :since ··· 150 \\SELECT f.uri, d.did, d.title, 151 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 152 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 153 + \\ d.platform, COALESCE(d.path, '') as path, 154 + \\ COALESCE(d.cover_image, '') as cover_image 155 \\FROM documents_fts f 156 \\JOIN documents d ON f.uri = d.uri 157 \\WHERE documents_fts MATCH :query AND d.platform = :platform ··· 162 \\SELECT f.uri, d.did, d.title, 163 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 164 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 165 + \\ d.platform, COALESCE(d.path, '') as path, 166 + \\ COALESCE(d.cover_image, '') as cover_image 167 \\FROM documents_fts f 168 \\JOIN documents d ON f.uri = d.uri 169 \\WHERE documents_fts MATCH :query AND d.platform = :platform AND d.created_at >= :since ··· 173 const DocsByTagAndPlatform = zql.Query( 174 \\SELECT d.uri, d.did, d.title, '' as snippet, 175 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 176 + \\ d.platform, COALESCE(d.path, '') as path, 177 + \\ COALESCE(d.cover_image, '') as cover_image 178 \\FROM documents d 179 \\JOIN document_tags dt ON d.uri = dt.document_uri 180 \\WHERE dt.tag = :tag AND d.platform = :platform ··· 185 \\SELECT f.uri, d.did, d.title, 186 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 187 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 188 + \\ d.platform, COALESCE(d.path, '') as path, 189 + \\ COALESCE(d.cover_image, '') as cover_image 190 \\FROM documents_fts f 191 \\JOIN documents d ON f.uri = d.uri 192 \\JOIN document_tags dt ON d.uri = dt.document_uri ··· 197 const DocsByPlatform = zql.Query( 198 \\SELECT d.uri, d.did, d.title, '' as snippet, 199 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 200 + \\ d.platform, COALESCE(d.path, '') as path, 201 + \\ COALESCE(d.cover_image, '') as cover_image 202 \\FROM documents d 203 \\WHERE d.platform = :platform 204 \\ORDER BY d.created_at DESC LIMIT 40 ··· 212 \\ d.created_at, d.rkey, 213 \\ p.base_path, 214 \\ 1 as has_publication, 215 + \\ d.platform, COALESCE(d.path, '') as path, 216 + \\ COALESCE(d.cover_image, '') as cover_image 217 \\FROM documents d 218 \\JOIN publications p ON d.publication_uri = p.uri 219 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 226 \\ d.created_at, d.rkey, 227 \\ p.base_path, 228 \\ 1 as has_publication, 229 + \\ d.platform, COALESCE(d.path, '') as path, 230 + \\ COALESCE(d.cover_image, '') as cover_image 231 \\FROM documents d 232 \\JOIN publications p ON d.publication_uri = p.uri 233 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 240 \\ d.created_at, d.rkey, 241 \\ p.base_path, 242 \\ 1 as has_publication, 243 + \\ d.platform, COALESCE(d.path, '') as path, 244 + \\ COALESCE(d.cover_image, '') as cover_image 245 \\FROM documents d 246 \\JOIN publications p ON d.publication_uri = p.uri 247 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 254 \\ d.created_at, d.rkey, 255 \\ p.base_path, 256 \\ 1 as has_publication, 257 + \\ d.platform, COALESCE(d.path, '') as path, 258 + \\ COALESCE(d.cover_image, '') as cover_image 259 \\FROM documents d 260 \\JOIN publications p ON d.publication_uri = p.uri 261 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 490 \\SELECT f.uri, d.did, d.title, 491 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 492 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 493 + \\ d.platform, COALESCE(d.path, '') as path, 494 + \\ COALESCE(d.cover_image, '') as cover_image 495 \\FROM documents_fts f 496 \\JOIN documents d ON f.uri = d.uri 497 \\WHERE documents_fts MATCH ? AND d.platform = ? ··· 514 var bp_rows = try local.query( 515 \\SELECT d.uri, d.did, d.title, '' as snippet, 516 \\ d.created_at, d.rkey, p.base_path, 517 + \\ 1 as has_publication, d.platform, COALESCE(d.path, '') as path, 518 + \\ COALESCE(d.cover_image, '') as cover_image 519 \\FROM documents d 520 \\JOIN publications p ON d.publication_uri = p.uri 521 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 539 \\SELECT f.uri, d.did, d.title, 540 \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 541 \\ d.created_at, d.rkey, d.base_path, d.has_publication, 542 + \\ d.platform, COALESCE(d.path, '') as path, 543 + \\ COALESCE(d.cover_image, '') as cover_image 544 \\FROM documents_fts f 545 \\JOIN documents d ON f.uri = d.uri 546 \\WHERE documents_fts MATCH ? ··· 570 var bp_rows = try local.query( 571 \\SELECT d.uri, d.did, d.title, '' as snippet, 572 \\ d.created_at, d.rkey, p.base_path, 573 + \\ 1 as has_publication, d.platform, COALESCE(d.path, '') as path, 574 + \\ COALESCE(d.cover_image, '') as cover_image 575 \\FROM documents d 576 \\JOIN publications p ON d.publication_uri = p.uri 577 \\JOIN publications_fts pf ON p.uri = pf.uri ··· 974 } 975 try jw.objectField("snippet"); 976 try jw.write(snippet); 977 + inline for (.{ "rkey", "basePath", "platform", "path", "coverImage" }) |field| { 978 try jw.objectField(field); 979 try jw.write(jsonStr(obj, field)); 980 }
+97 -38
site/dashboard.css
··· 1 * { box-sizing: border-box; margin: 0; padding: 0; } 2 3 body { 4 font-family: monospace; 5 - background: #0a0a0a; 6 - color: #ccc; 7 min-height: 100vh; 8 padding: 1rem; 9 font-size: 14px; ··· 20 font-weight: normal; 21 margin-bottom: 1.5rem; 22 } 23 - h1 a.title { color: #888; } 24 - h1 a.title:hover { color: #fff; } 25 - h1 .dim { color: #555; } 26 27 section { margin-bottom: 2rem; } 28 29 .section-title { 30 font-size: 11px; 31 - color: #555; 32 margin-bottom: 0.75rem; 33 } 34 ··· 40 41 .metric-value { 42 font-size: 16px; 43 - color: #888; 44 font-weight: normal; 45 } 46 47 .metric-label { 48 font-size: 10px; 49 - color: #444; 50 text-transform: uppercase; 51 letter-spacing: 0.5px; 52 } 53 54 .chart-box { 55 - background: #111; 56 - border: 1px solid #222; 57 padding: 1rem; 58 margin-bottom: 1rem; 59 } ··· 62 display: flex; 63 justify-content: space-between; 64 font-size: 11px; 65 - color: #666; 66 margin-bottom: 0.75rem; 67 } 68 ··· 85 justify-content: space-between; 86 font-size: 12px; 87 padding: 0.25rem 0; 88 - border-bottom: 1px solid #1a1a1a; 89 } 90 .doc-row:last-child { border-bottom: none; } 91 - .doc-type { color: #888; } 92 - .doc-count { color: #ccc; } 93 94 .pub-row { 95 display: flex; 96 justify-content: space-between; 97 font-size: 12px; 98 padding: 0.25rem 0; 99 - border-bottom: 1px solid #1a1a1a; 100 } 101 .pub-row:last-child { border-bottom: none; } 102 - .pub-name { color: #888; } 103 a.pub-name { color: #1B7340; } 104 a.pub-name:hover { color: #2a9d5c; } 105 - .pub-count { color: #666; } 106 107 .timing-row { 108 display: flex; 109 justify-content: space-between; 110 font-size: 12px; 111 padding: 0.25rem 0; 112 - border-bottom: 1px solid #1a1a1a; 113 } 114 .timing-row:last-child { border-bottom: none; } 115 - .timing-name { color: #888; } 116 - .timing-value { color: #ccc; } 117 - .timing-value .dim { color: #555; } 118 119 .latency-grid { 120 display: grid; ··· 138 align-items: center; 139 gap: 4px; 140 font-size: 10px; 141 - color: #666; 142 margin-bottom: 4px; 143 } 144 .latency-cell-label .dot { ··· 148 } 149 .latency-cell-max { 150 margin-left: auto; 151 - color: #444; 152 } 153 .latency-cell-empty { 154 height: 40px; 155 display: flex; 156 align-items: center; 157 justify-content: center; 158 - color: #333; 159 font-size: 11px; 160 } 161 .latency-tooltip { ··· 163 bottom: 2px; 164 right: 4px; 165 font-size: 9px; 166 - color: #555; 167 white-space: nowrap; 168 pointer-events: none; 169 opacity: 0; 170 transition: opacity 0.1s; 171 - background: rgba(17, 17, 17, 0.9); 172 padding: 1px 4px; 173 } 174 ··· 181 .tag { 182 font-size: 11px; 183 padding: 3px 8px; 184 - background: #151515; 185 - border: 1px solid #252525; 186 border-radius: 3px; 187 - color: #777; 188 } 189 .tag:hover { 190 - background: #1a1a1a; 191 - border-color: #333; 192 - color: #aaa; 193 } 194 - .tag .n { color: #444; margin-left: 4px; } 195 196 - .live { font-size: 11px; color: #555; } 197 .live span { color: #4ade80; } 198 199 footer { 200 margin-top: 2rem; 201 padding-top: 1rem; 202 - border-top: 1px solid #222; 203 font-size: 11px; 204 - color: #444; 205 } 206 - footer a { color: #555; } 207 - footer a:hover { color: #888; }
··· 1 + :root, [data-theme="dark"] { 2 + --bg: #0a0a0a; 3 + --bg-subtle: #111; 4 + --bg-hover: #151515; 5 + --border: #222; 6 + --border-subtle: #252525; 7 + --border-focus: #333; 8 + --text: #ccc; 9 + --text-bright: #fff; 10 + --text-secondary: #888; 11 + --text-dim: #555; 12 + --text-muted: #444; 13 + --row-border: #1a1a1a; 14 + --tooltip-bg: rgba(17, 17, 17, 0.9); 15 + } 16 + 17 + [data-theme="light"] { 18 + --bg: #f5f5f0; 19 + --bg-subtle: #eee; 20 + --bg-hover: #e4e4e0; 21 + --border: #ddd; 22 + --border-subtle: #ccc; 23 + --border-focus: #bbb; 24 + --text: #333; 25 + --text-bright: #111; 26 + --text-secondary: #555; 27 + --text-dim: #888; 28 + --text-muted: #999; 29 + --row-border: #e0e0e0; 30 + --tooltip-bg: rgba(238, 238, 238, 0.9); 31 + } 32 + 33 * { box-sizing: border-box; margin: 0; padding: 0; } 34 35 body { 36 font-family: monospace; 37 + background: var(--bg); 38 + color: var(--text); 39 min-height: 100vh; 40 padding: 1rem; 41 font-size: 14px; ··· 52 font-weight: normal; 53 margin-bottom: 1.5rem; 54 } 55 + h1 a.title { color: var(--text-secondary); } 56 + h1 a.title:hover { color: var(--text-bright); } 57 + h1 .dim { color: var(--text-dim); } 58 59 section { margin-bottom: 2rem; } 60 61 .section-title { 62 font-size: 11px; 63 + color: var(--text-dim); 64 margin-bottom: 0.75rem; 65 } 66 ··· 72 73 .metric-value { 74 font-size: 16px; 75 + color: var(--text-secondary); 76 font-weight: normal; 77 } 78 79 .metric-label { 80 font-size: 10px; 81 + color: var(--text-muted); 82 text-transform: uppercase; 83 letter-spacing: 0.5px; 84 } 85 86 .chart-box { 87 + background: var(--bg-subtle); 88 + border: 1px solid var(--border); 89 padding: 1rem; 90 margin-bottom: 1rem; 91 } ··· 94 display: flex; 95 justify-content: space-between; 96 font-size: 11px; 97 + color: var(--text-dim); 98 margin-bottom: 0.75rem; 99 } 100 ··· 117 justify-content: space-between; 118 font-size: 12px; 119 padding: 0.25rem 0; 120 + border-bottom: 1px solid var(--row-border); 121 } 122 .doc-row:last-child { border-bottom: none; } 123 + .doc-type { color: var(--text-secondary); } 124 + .doc-count { color: var(--text); } 125 126 .pub-row { 127 display: flex; 128 justify-content: space-between; 129 font-size: 12px; 130 padding: 0.25rem 0; 131 + border-bottom: 1px solid var(--row-border); 132 } 133 .pub-row:last-child { border-bottom: none; } 134 + .pub-name { color: var(--text-secondary); } 135 a.pub-name { color: #1B7340; } 136 a.pub-name:hover { color: #2a9d5c; } 137 + .pub-count { color: var(--text-dim); } 138 139 .timing-row { 140 display: flex; 141 justify-content: space-between; 142 font-size: 12px; 143 padding: 0.25rem 0; 144 + border-bottom: 1px solid var(--row-border); 145 } 146 .timing-row:last-child { border-bottom: none; } 147 + .timing-name { color: var(--text-secondary); } 148 + .timing-value { color: var(--text); } 149 + .timing-value .dim { color: var(--text-dim); } 150 151 .latency-grid { 152 display: grid; ··· 170 align-items: center; 171 gap: 4px; 172 font-size: 10px; 173 + color: var(--text-dim); 174 margin-bottom: 4px; 175 } 176 .latency-cell-label .dot { ··· 180 } 181 .latency-cell-max { 182 margin-left: auto; 183 + color: var(--text-muted); 184 } 185 .latency-cell-empty { 186 height: 40px; 187 display: flex; 188 align-items: center; 189 justify-content: center; 190 + color: var(--text-muted); 191 font-size: 11px; 192 } 193 .latency-tooltip { ··· 195 bottom: 2px; 196 right: 4px; 197 font-size: 9px; 198 + color: var(--text-dim); 199 white-space: nowrap; 200 pointer-events: none; 201 opacity: 0; 202 transition: opacity 0.1s; 203 + background: var(--tooltip-bg); 204 padding: 1px 4px; 205 } 206 ··· 213 .tag { 214 font-size: 11px; 215 padding: 3px 8px; 216 + background: var(--bg-hover); 217 + border: 1px solid var(--border-subtle); 218 border-radius: 3px; 219 + color: var(--text-dim); 220 } 221 .tag:hover { 222 + background: var(--bg-hover); 223 + border-color: var(--border-focus); 224 + color: var(--text); 225 } 226 + .tag .n { color: var(--text-muted); margin-left: 4px; } 227 228 + .live { font-size: 11px; color: var(--text-dim); } 229 .live span { color: #4ade80; } 230 231 + .theme-toggle { 232 + display: inline-flex; 233 + gap: 0.25rem; 234 + margin-left: 0.5rem; 235 + } 236 + 237 + .theme-option { 238 + font-size: 10px; 239 + padding: 1px 5px; 240 + background: var(--bg-hover); 241 + border: 1px solid var(--border-subtle); 242 + border-radius: 3px; 243 + cursor: pointer; 244 + color: var(--text-dim); 245 + } 246 + 247 + .theme-option:hover { 248 + border-color: var(--border-focus); 249 + color: var(--text); 250 + } 251 + 252 + .theme-option.active { 253 + background: rgba(100, 100, 100, 0.3); 254 + border-color: var(--text-dim); 255 + color: var(--text-secondary); 256 + } 257 + 258 footer { 259 margin-top: 2rem; 260 padding-top: 1rem; 261 + border-top: 1px solid var(--border); 262 font-size: 11px; 263 + color: var(--text-muted); 264 } 265 + footer a { color: var(--text-dim); } 266 + footer a:hover { color: var(--text-secondary); }
+30 -1
site/dashboard.html
··· 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>pub search / stats</title> 7 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='4' y='18' width='6' height='10' fill='%231B7340'/><rect x='13' y='12' width='6' height='16' fill='%231B7340'/><rect x='22' y='6' width='6' height='22' fill='%231B7340'/></svg>"> 8 <link rel="stylesheet" href="dashboard.css"> 9 </head> 10 <body> 11 <div class="container"> 12 - <h1><a href="https://pub-search.waow.tech" class="title">pub search</a> <span class="dim">/ stats</span></h1> 13 14 <section> 15 <div class="metrics"> ··· 82 </footer> 83 </div> 84 85 <script src="loading.js"></script> 86 <script src="dashboard.js"></script> 87 </body>
··· 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>pub search / stats</title> 7 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='4' y='18' width='6' height='10' fill='%231B7340'/><rect x='13' y='12' width='6' height='16' fill='%231B7340'/><rect x='22' y='6' width='6' height='22' fill='%231B7340'/></svg>"> 8 + <script> 9 + (function() { 10 + var t = localStorage.getItem('theme') || 'dark'; 11 + if (t === 'system') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; 12 + document.documentElement.setAttribute('data-theme', t); 13 + })(); 14 + </script> 15 <link rel="stylesheet" href="dashboard.css"> 16 </head> 17 <body> 18 <div class="container"> 19 + <h1><a href="https://pub-search.waow.tech" class="title">pub search</a> <span class="dim">/ stats</span> <span class="theme-toggle" id="theme-toggle"></span></h1> 20 21 <section> 22 <div class="metrics"> ··· 89 </footer> 90 </div> 91 92 + <script> 93 + var currentTheme = localStorage.getItem('theme') || 'dark'; 94 + function applyTheme(theme) { 95 + currentTheme = theme; 96 + localStorage.setItem('theme', theme); 97 + var resolved = theme; 98 + if (theme === 'system') resolved = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; 99 + document.documentElement.setAttribute('data-theme', resolved); 100 + renderThemeToggle(); 101 + } 102 + function renderThemeToggle() { 103 + var el = document.getElementById('theme-toggle'); 104 + if (!el) return; 105 + el.innerHTML = ['dark', 'light', 'system'].map(function(t) { 106 + return '<span class="theme-option' + (currentTheme === t ? ' active' : '') + '" onclick="applyTheme(\'' + t + '\')">' + t + '</span>'; 107 + }).join(''); 108 + } 109 + matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() { 110 + if (currentTheme === 'system') applyTheme('system'); 111 + }); 112 + renderThemeToggle(); 113 + </script> 114 <script src="loading.js"></script> 115 <script src="dashboard.js"></script> 116 </body>
+218 -91
site/index.html
··· 16 <meta name="twitter:title" content="pub search"> 17 <meta name="twitter:description" content="search atproto publishing platforms"> 18 <meta name="twitter:image" content="https://pub-search.waow.tech/og-image"> 19 <style> 20 * { box-sizing: border-box; margin: 0; padding: 0; } 21 22 body { 23 font-family: monospace; 24 - background: #0a0a0a; 25 - color: #ccc; 26 min-height: 100vh; 27 padding: 1rem; 28 font-size: 14px; ··· 44 } 45 46 h1 a.title { 47 - color: #888; 48 } 49 50 h1 a.title:hover { 51 - color: #fff; 52 } 53 54 h1 a.src { 55 font-size: 10px; 56 - color: #444; 57 } 58 59 h1 a.stats-link { ··· 67 68 h1 .by { 69 font-size: 10px; 70 - color: #555; 71 } 72 73 h1 .by a { 74 - color: #555; 75 } 76 77 h1 .by a:hover { ··· 89 padding: 0.5rem; 90 font-family: monospace; 91 font-size: 16px; /* prevents iOS auto-zoom on focus */ 92 - background: #111; 93 - border: 1px solid #333; 94 - color: #ccc; 95 } 96 97 input[type="text"]:focus { outline: 1px solid #1B7340; } ··· 100 padding: 0.5rem 1rem; 101 font-family: monospace; 102 font-size: 14px; 103 - background: #111; 104 - border: 1px solid #333; 105 - color: #ccc; 106 cursor: pointer; 107 } 108 109 - button:hover { background: #222; } 110 - button:disabled { color: #555; cursor: not-allowed; } 111 112 .results { 113 display: flex; ··· 115 } 116 117 .result { 118 - border-bottom: 1px solid #222; 119 padding: 1rem 0; 120 } 121 122 - .result:hover { background: #111; margin: 0 -0.5rem; padding: 1rem 0.5rem; } 123 124 .result-title { 125 - color: #fff; 126 margin-bottom: 0.5rem; 127 /* prevent long titles from breaking layout */ 128 display: -webkit-box; ··· 137 138 .result-snippet { 139 font-size: 12px; 140 - color: #888; 141 margin-bottom: 0.5rem; 142 line-height: 1.5; 143 } ··· 150 151 .result-meta { 152 font-size: 11px; 153 - color: #555; 154 } 155 156 .author-name a { 157 - color: #888; 158 text-decoration: none; 159 } 160 ··· 177 178 .entity-type.looseleaf { 179 background: rgba(100, 100, 100, 0.2); 180 - color: #888; 181 } 182 183 .entity-type.publication { ··· 198 .status { 199 padding: 1rem; 200 text-align: center; 201 - color: #666; 202 } 203 204 - .error { color: #c44; } 205 206 .empty-state { 207 text-align: center; 208 padding: 2rem; 209 - color: #555; 210 } 211 212 .empty-state p { margin-bottom: 0.5rem; } 213 214 .suggestions { 215 font-size: 11px; 216 - color: #444; 217 margin-bottom: 1.5rem; 218 padding: 0.5rem 0; 219 - border-bottom: 1px solid #1a1a1a; 220 } 221 222 .suggestions-label { 223 - color: #555; 224 margin-right: 0.25rem; 225 } 226 ··· 229 } 230 231 .suggestion { 232 - color: #666; 233 cursor: pointer; 234 font-style: italic; 235 } 236 237 .suggestion::before { 238 content: '"'; 239 - color: #444; 240 } 241 242 .suggestion::after { 243 content: '"'; 244 - color: #444; 245 margin-right: 0.5rem; 246 } 247 ··· 256 257 .stats { 258 font-size: 11px; 259 - color: #555; 260 margin-top: 1.5rem; 261 text-align: center; 262 } ··· 268 269 .related-header { 270 font-size: 11px; 271 - color: #444; 272 margin-bottom: 0.75rem; 273 } 274 275 .related-loading { 276 font-size: 11px; 277 - color: #666; 278 margin-top: 1rem; 279 animation: pulse 1.5s ease-in-out infinite; 280 } ··· 293 .related-item { 294 font-size: 12px; 295 padding: 0.4rem 0.6rem; 296 - background: #111; 297 - border: 1px solid #222; 298 border-radius: 3px; 299 - color: #888; 300 text-decoration: none; 301 max-width: 200px; 302 overflow: hidden; ··· 305 } 306 307 .related-item:hover { 308 - background: #1a1a1a; 309 - border-color: #333; 310 - color: #aaa; 311 } 312 313 .tags { ··· 316 317 .tags-label { 318 font-size: 11px; 319 - color: #444; 320 margin-bottom: 0.5rem; 321 } 322 ··· 329 .tag { 330 font-size: 11px; 331 padding: 3px 8px; 332 - background: #151515; 333 - border: 1px solid #252525; 334 border-radius: 3px; 335 cursor: pointer; 336 - color: #777; 337 } 338 339 .tag:hover { 340 - background: #1a1a1a; 341 - border-color: #333; 342 - color: #aaa; 343 } 344 345 .tag.active { ··· 349 } 350 351 .tag .count { 352 - color: #444; 353 margin-left: 4px; 354 } 355 ··· 357 /* match .tag styling exactly */ 358 font-size: 11px; 359 padding: 3px 8px; 360 - background: #151515; 361 - border: 1px solid #252525; 362 border-radius: 3px; 363 - color: #777; 364 font-family: monospace; 365 /* prevent flex expansion from global input[type="text"] */ 366 flex: none; ··· 368 } 369 370 input.tag-input:hover { 371 - background: #1a1a1a; 372 - border-color: #333; 373 - color: #aaa; 374 } 375 376 input.tag-input:focus { 377 outline: none; 378 border-color: #1B7340; 379 background: rgba(27, 115, 64, 0.1); 380 - color: #ccc; 381 } 382 383 input.tag-input::placeholder { 384 - color: #555; 385 } 386 387 .platform-filter { ··· 390 391 .platform-filter-label { 392 font-size: 11px; 393 - color: #444; 394 margin-bottom: 0.5rem; 395 } 396 ··· 402 .platform-option { 403 font-size: 11px; 404 padding: 3px 8px; 405 - background: #151515; 406 - border: 1px solid #252525; 407 border-radius: 3px; 408 cursor: pointer; 409 - color: #777; 410 } 411 412 .platform-option:hover { 413 - background: #1a1a1a; 414 - border-color: #333; 415 - color: #aaa; 416 } 417 418 .platform-option.active { ··· 427 428 .date-filter-label { 429 font-size: 11px; 430 - color: #444; 431 margin-bottom: 0.5rem; 432 } 433 ··· 439 .date-option { 440 font-size: 11px; 441 padding: 3px 8px; 442 - background: #151515; 443 - border: 1px solid #252525; 444 border-radius: 3px; 445 cursor: pointer; 446 - color: #777; 447 } 448 449 .date-option:hover { 450 - background: #1a1a1a; 451 - border-color: #333; 452 - color: #aaa; 453 } 454 455 .date-option.active { ··· 469 } 470 471 .active-filters-label { 472 - color: #555; 473 } 474 475 .filter-chip { ··· 507 508 .filter-chip .x:hover { 509 opacity: 1; 510 - color: #c44; 511 } 512 513 .mode-toggle { ··· 516 517 .mode-toggle-label { 518 font-size: 11px; 519 - color: #444; 520 margin-bottom: 0.5rem; 521 } 522 ··· 528 .mode-option { 529 font-size: 11px; 530 padding: 3px 8px; 531 - background: #151515; 532 - border: 1px solid #252525; 533 border-radius: 3px; 534 cursor: pointer; 535 - color: #777; 536 } 537 538 .mode-option:hover { 539 - background: #1a1a1a; 540 - border-color: #333; 541 - color: #aaa; 542 } 543 544 .mode-option.active { ··· 570 color: #34d399; 571 } 572 573 /* mobile improvements */ 574 @media (max-width: 600px) { 575 body { ··· 656 font-size: 11px; 657 padding: 0.5rem; 658 } 659 } 660 661 /* ensure touch targets on tablets too */ ··· 670 </head> 671 <body> 672 <div class="container"> 673 - <h1><a href="/" class="title">pub search</a> <span class="by">by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank">@zzstoatzz.io</a></span> <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search" target="_blank" class="src">[src]</a> <a id="header-stats" class="stats-link"></a></h1> 674 675 <div class="search-box"> 676 <input type="text" id="query" placeholder="search content..." autofocus> ··· 704 ? 'http://localhost:8080' 705 : 'https://leaflet-search-backend.fly.dev'; 706 707 const queryInput = document.getElementById('query'); 708 const searchBtn = document.getElementById('search-btn'); 709 const resultsDiv = document.getElementById('results'); ··· 811 ? `<div class="result-snippet">${highlightTerms(doc.snippet, query)}</div>` 812 : ''; 813 814 html += ` 815 <div class="result"> 816 - <div class="result-title"> 817 - <span class="entity-type ${entityType}">${entityType}</span>${platformBadge} 818 - ${docUrl 819 - ? `<a href="${docUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 820 - : escapeHtml(doc.title || 'Untitled')}${sourceBadge} 821 - </div> 822 - ${snippetHtml} 823 - <div class="result-meta" ${doc.did ? `data-did="${escapeHtml(doc.did)}"` : ''}> 824 - ${date ? `${date} | ` : ''}<span class="author-name"></span>${platformHome.url 825 - ? `<a href="${platformHome.url}" target="_blank">${platformHome.label}</a>` 826 - : platformHome.label} 827 </div> 828 </div> 829 `;
··· 16 <meta name="twitter:title" content="pub search"> 17 <meta name="twitter:description" content="search atproto publishing platforms"> 18 <meta name="twitter:image" content="https://pub-search.waow.tech/og-image"> 19 + <script> 20 + // prevent flash: apply theme before CSS loads 21 + (function() { 22 + var t = localStorage.getItem('theme') || 'dark'; 23 + if (t === 'system') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; 24 + document.documentElement.setAttribute('data-theme', t); 25 + })(); 26 + </script> 27 <style> 28 + :root, [data-theme="dark"] { 29 + --bg: #0a0a0a; 30 + --bg-subtle: #111; 31 + --bg-hover: #151515; 32 + --border: #222; 33 + --border-subtle: #252525; 34 + --border-focus: #333; 35 + --text: #ccc; 36 + --text-bright: #fff; 37 + --text-secondary: #888; 38 + --text-dim: #555; 39 + --text-muted: #444; 40 + --error: #c44; 41 + --result-hover: #111; 42 + --row-border: #1a1a1a; 43 + --tooltip-bg: rgba(17, 17, 17, 0.9); 44 + } 45 + 46 + [data-theme="light"] { 47 + --bg: #f5f5f0; 48 + --bg-subtle: #eee; 49 + --bg-hover: #e4e4e0; 50 + --border: #ddd; 51 + --border-subtle: #ccc; 52 + --border-focus: #bbb; 53 + --text: #333; 54 + --text-bright: #111; 55 + --text-secondary: #555; 56 + --text-dim: #888; 57 + --text-muted: #999; 58 + --error: #c44; 59 + --result-hover: #eee; 60 + --row-border: #e0e0e0; 61 + --tooltip-bg: rgba(238, 238, 238, 0.9); 62 + } 63 + 64 * { box-sizing: border-box; margin: 0; padding: 0; } 65 66 body { 67 font-family: monospace; 68 + background: var(--bg); 69 + color: var(--text); 70 min-height: 100vh; 71 padding: 1rem; 72 font-size: 14px; ··· 88 } 89 90 h1 a.title { 91 + color: var(--text-secondary); 92 } 93 94 h1 a.title:hover { 95 + color: var(--text-bright); 96 } 97 98 h1 a.src { 99 font-size: 10px; 100 + color: var(--text-muted); 101 } 102 103 h1 a.stats-link { ··· 111 112 h1 .by { 113 font-size: 10px; 114 + color: var(--text-dim); 115 } 116 117 h1 .by a { 118 + color: var(--text-dim); 119 } 120 121 h1 .by a:hover { ··· 133 padding: 0.5rem; 134 font-family: monospace; 135 font-size: 16px; /* prevents iOS auto-zoom on focus */ 136 + background: var(--bg-subtle); 137 + border: 1px solid var(--border-focus); 138 + color: var(--text); 139 } 140 141 input[type="text"]:focus { outline: 1px solid #1B7340; } ··· 144 padding: 0.5rem 1rem; 145 font-family: monospace; 146 font-size: 14px; 147 + background: var(--bg-subtle); 148 + border: 1px solid var(--border-focus); 149 + color: var(--text); 150 cursor: pointer; 151 } 152 153 + button:hover { background: var(--border); } 154 + button:disabled { color: var(--text-dim); cursor: not-allowed; } 155 156 .results { 157 display: flex; ··· 159 } 160 161 .result { 162 + border-bottom: 1px solid var(--border); 163 padding: 1rem 0; 164 } 165 166 + .result:hover { background: var(--result-hover); margin: 0 -0.5rem; padding: 1rem 0.5rem; } 167 168 .result-title { 169 + color: var(--text-bright); 170 margin-bottom: 0.5rem; 171 /* prevent long titles from breaking layout */ 172 display: -webkit-box; ··· 181 182 .result-snippet { 183 font-size: 12px; 184 + color: var(--text-secondary); 185 margin-bottom: 0.5rem; 186 line-height: 1.5; 187 } ··· 194 195 .result-meta { 196 font-size: 11px; 197 + color: var(--text-dim); 198 } 199 200 .author-name a { 201 + color: var(--text-secondary); 202 text-decoration: none; 203 } 204 ··· 221 222 .entity-type.looseleaf { 223 background: rgba(100, 100, 100, 0.2); 224 + color: var(--text-secondary); 225 } 226 227 .entity-type.publication { ··· 242 .status { 243 padding: 1rem; 244 text-align: center; 245 + color: var(--text-dim); 246 } 247 248 + .error { color: var(--error); } 249 250 .empty-state { 251 text-align: center; 252 padding: 2rem; 253 + color: var(--text-dim); 254 } 255 256 .empty-state p { margin-bottom: 0.5rem; } 257 258 .suggestions { 259 font-size: 11px; 260 + color: var(--text-muted); 261 margin-bottom: 1.5rem; 262 padding: 0.5rem 0; 263 + border-bottom: 1px solid var(--row-border); 264 } 265 266 .suggestions-label { 267 + color: var(--text-dim); 268 margin-right: 0.25rem; 269 } 270 ··· 273 } 274 275 .suggestion { 276 + color: var(--text-dim); 277 cursor: pointer; 278 font-style: italic; 279 } 280 281 .suggestion::before { 282 content: '"'; 283 + color: var(--text-muted); 284 } 285 286 .suggestion::after { 287 content: '"'; 288 + color: var(--text-muted); 289 margin-right: 0.5rem; 290 } 291 ··· 300 301 .stats { 302 font-size: 11px; 303 + color: var(--text-dim); 304 margin-top: 1.5rem; 305 text-align: center; 306 } ··· 312 313 .related-header { 314 font-size: 11px; 315 + color: var(--text-muted); 316 margin-bottom: 0.75rem; 317 } 318 319 .related-loading { 320 font-size: 11px; 321 + color: var(--text-dim); 322 margin-top: 1rem; 323 animation: pulse 1.5s ease-in-out infinite; 324 } ··· 337 .related-item { 338 font-size: 12px; 339 padding: 0.4rem 0.6rem; 340 + background: var(--bg-subtle); 341 + border: 1px solid var(--border); 342 border-radius: 3px; 343 + color: var(--text-secondary); 344 text-decoration: none; 345 max-width: 200px; 346 overflow: hidden; ··· 349 } 350 351 .related-item:hover { 352 + background: var(--bg-hover); 353 + border-color: var(--border-focus); 354 + color: var(--text); 355 } 356 357 .tags { ··· 360 361 .tags-label { 362 font-size: 11px; 363 + color: var(--text-muted); 364 margin-bottom: 0.5rem; 365 } 366 ··· 373 .tag { 374 font-size: 11px; 375 padding: 3px 8px; 376 + background: var(--bg-hover); 377 + border: 1px solid var(--border-subtle); 378 border-radius: 3px; 379 cursor: pointer; 380 + color: var(--text-dim); 381 } 382 383 .tag:hover { 384 + background: var(--bg-hover); 385 + border-color: var(--border-focus); 386 + color: var(--text); 387 } 388 389 .tag.active { ··· 393 } 394 395 .tag .count { 396 + color: var(--text-muted); 397 margin-left: 4px; 398 } 399 ··· 401 /* match .tag styling exactly */ 402 font-size: 11px; 403 padding: 3px 8px; 404 + background: var(--bg-hover); 405 + border: 1px solid var(--border-subtle); 406 border-radius: 3px; 407 + color: var(--text-dim); 408 font-family: monospace; 409 /* prevent flex expansion from global input[type="text"] */ 410 flex: none; ··· 412 } 413 414 input.tag-input:hover { 415 + background: var(--bg-hover); 416 + border-color: var(--border-focus); 417 + color: var(--text); 418 } 419 420 input.tag-input:focus { 421 outline: none; 422 border-color: #1B7340; 423 background: rgba(27, 115, 64, 0.1); 424 + color: var(--text); 425 } 426 427 input.tag-input::placeholder { 428 + color: var(--text-dim); 429 } 430 431 .platform-filter { ··· 434 435 .platform-filter-label { 436 font-size: 11px; 437 + color: var(--text-muted); 438 margin-bottom: 0.5rem; 439 } 440 ··· 446 .platform-option { 447 font-size: 11px; 448 padding: 3px 8px; 449 + background: var(--bg-hover); 450 + border: 1px solid var(--border-subtle); 451 border-radius: 3px; 452 cursor: pointer; 453 + color: var(--text-dim); 454 } 455 456 .platform-option:hover { 457 + background: var(--bg-hover); 458 + border-color: var(--border-focus); 459 + color: var(--text); 460 } 461 462 .platform-option.active { ··· 471 472 .date-filter-label { 473 font-size: 11px; 474 + color: var(--text-muted); 475 margin-bottom: 0.5rem; 476 } 477 ··· 483 .date-option { 484 font-size: 11px; 485 padding: 3px 8px; 486 + background: var(--bg-hover); 487 + border: 1px solid var(--border-subtle); 488 border-radius: 3px; 489 cursor: pointer; 490 + color: var(--text-dim); 491 } 492 493 .date-option:hover { 494 + background: var(--bg-hover); 495 + border-color: var(--border-focus); 496 + color: var(--text); 497 } 498 499 .date-option.active { ··· 513 } 514 515 .active-filters-label { 516 + color: var(--text-dim); 517 } 518 519 .filter-chip { ··· 551 552 .filter-chip .x:hover { 553 opacity: 1; 554 + color: var(--error); 555 } 556 557 .mode-toggle { ··· 560 561 .mode-toggle-label { 562 font-size: 11px; 563 + color: var(--text-muted); 564 margin-bottom: 0.5rem; 565 } 566 ··· 572 .mode-option { 573 font-size: 11px; 574 padding: 3px 8px; 575 + background: var(--bg-hover); 576 + border: 1px solid var(--border-subtle); 577 border-radius: 3px; 578 cursor: pointer; 579 + color: var(--text-dim); 580 } 581 582 .mode-option:hover { 583 + background: var(--bg-hover); 584 + border-color: var(--border-focus); 585 + color: var(--text); 586 } 587 588 .mode-option.active { ··· 614 color: #34d399; 615 } 616 617 + .theme-toggle { 618 + display: inline-flex; 619 + gap: 0.25rem; 620 + margin-left: 0.5rem; 621 + } 622 + 623 + .theme-option { 624 + font-size: 10px; 625 + padding: 1px 5px; 626 + background: var(--bg-hover); 627 + border: 1px solid var(--border-subtle); 628 + border-radius: 3px; 629 + cursor: pointer; 630 + color: var(--text-dim); 631 + } 632 + 633 + .theme-option:hover { 634 + border-color: var(--border-focus); 635 + color: var(--text); 636 + } 637 + 638 + .theme-option.active { 639 + background: rgba(100, 100, 100, 0.3); 640 + border-color: var(--text-dim); 641 + color: var(--text-secondary); 642 + } 643 + 644 + .cover-thumb { 645 + width: 32px; 646 + height: 32px; 647 + border-radius: 4px; 648 + object-fit: cover; 649 + flex-shrink: 0; 650 + } 651 + 652 + .result-row { 653 + display: flex; 654 + gap: 0.75rem; 655 + align-items: flex-start; 656 + } 657 + 658 + .result-content { 659 + flex: 1; 660 + min-width: 0; 661 + } 662 + 663 /* mobile improvements */ 664 @media (max-width: 600px) { 665 body { ··· 746 font-size: 11px; 747 padding: 0.5rem; 748 } 749 + 750 + .cover-thumb { 751 + width: 28px; 752 + height: 28px; 753 + } 754 } 755 756 /* ensure touch targets on tablets too */ ··· 765 </head> 766 <body> 767 <div class="container"> 768 + <h1><a href="/" class="title">pub search</a> <span class="by">by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank">@zzstoatzz.io</a></span> <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search" target="_blank" class="src">[src]</a> <a id="header-stats" class="stats-link"></a> <span class="theme-toggle" id="theme-toggle"></span></h1> 769 770 <div class="search-box"> 771 <input type="text" id="query" placeholder="search content..." autofocus> ··· 799 ? 'http://localhost:8080' 800 : 'https://leaflet-search-backend.fly.dev'; 801 802 + // theme toggle 803 + let currentTheme = localStorage.getItem('theme') || 'dark'; 804 + function applyTheme(theme) { 805 + currentTheme = theme; 806 + localStorage.setItem('theme', theme); 807 + let resolved = theme; 808 + if (theme === 'system') resolved = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; 809 + document.documentElement.setAttribute('data-theme', resolved); 810 + renderThemeToggle(); 811 + } 812 + function renderThemeToggle() { 813 + const el = document.getElementById('theme-toggle'); 814 + if (!el) return; 815 + el.innerHTML = ['dark', 'light', 'system'].map(t => 816 + `<span class="theme-option${currentTheme === t ? ' active' : ''}" onclick="applyTheme('${t}')">${t}</span>` 817 + ).join(''); 818 + } 819 + matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { 820 + if (currentTheme === 'system') applyTheme('system'); 821 + }); 822 + renderThemeToggle(); 823 + 824 const queryInput = document.getElementById('query'); 825 const searchBtn = document.getElementById('search-btn'); 826 const resultsDiv = document.getElementById('results'); ··· 928 ? `<div class="result-snippet">${highlightTerms(doc.snippet, query)}</div>` 929 : ''; 930 931 + // cover image thumbnail (only when present) 932 + const coverHtml = doc.coverImage && doc.did 933 + ? `<img class="cover-thumb" src="https://cdn.bsky.app/img/feed_thumbnail/plain/${doc.did}/${doc.coverImage}@jpeg" alt="" loading="lazy" onerror="this.remove()">` 934 + : ''; 935 + 936 html += ` 937 <div class="result"> 938 + <div class="result-row"> 939 + ${coverHtml} 940 + <div class="result-content"> 941 + <div class="result-title"> 942 + <span class="entity-type ${entityType}">${entityType}</span>${platformBadge} 943 + ${docUrl 944 + ? `<a href="${docUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 945 + : escapeHtml(doc.title || 'Untitled')}${sourceBadge} 946 + </div> 947 + ${snippetHtml} 948 + <div class="result-meta" ${doc.did ? `data-did="${escapeHtml(doc.did)}"` : ''}> 949 + ${date ? `${date} | ` : ''}<span class="author-name"></span>${platformHome.url 950 + ? `<a href="${platformHome.url}" target="_blank">${platformHome.label}</a>` 951 + : platformHome.label} 952 + </div> 953 + </div> 954 </div> 955 </div> 956 `;