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