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

fix: sync cover_image to local replica, cycling theme icon, clickable platform badges

- Add cover_image column to local SQLite schema and sync queries
- Replace 3-button theme toggle with single cycling icon (dark/light/system)
- Make platform badges clickable as search filters
- Fix cover image hover jump with position compensation
- Move cover thumbnail to right-side absolute positioning
- Add backfill-cover-images script for populating existing docs

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

+361 -87
+2 -1
backend/src/db/LocalDb.zig
··· 140 140 \\ path TEXT, 141 141 \\ base_path TEXT DEFAULT '', 142 142 \\ has_publication INTEGER DEFAULT 0, 143 - \\ indexed_at TEXT 143 + \\ indexed_at TEXT, 144 + \\ cover_image TEXT DEFAULT '' 144 145 \\) 145 146 , .{}) catch |err| { 146 147 std.debug.print("local db: failed to create documents table: {}\n", .{err});
+7 -4
backend/src/db/sync.zig
··· 40 40 // fetch from Turso (no lock held — search can use local DB) 41 41 var result = turso.query( 42 42 \\SELECT uri, did, rkey, title, content, created_at, publication_uri, 43 - \\ platform, source_collection, path, base_path, has_publication, indexed_at, embedded_at 43 + \\ platform, source_collection, path, base_path, has_publication, indexed_at, embedded_at, 44 + \\ COALESCE(cover_image, '') as cover_image 44 45 \\FROM documents 45 46 \\ORDER BY uri 46 47 \\LIMIT 500 OFFSET ? ··· 236 237 { 237 238 var result = turso.query( 238 239 \\SELECT uri, did, rkey, title, content, created_at, publication_uri, 239 - \\ platform, source_collection, path, base_path, has_publication, indexed_at, embedded_at 240 + \\ platform, source_collection, path, base_path, has_publication, indexed_at, embedded_at, 241 + \\ COALESCE(cover_image, '') as cover_image 240 242 \\FROM documents 241 243 \\WHERE indexed_at >= ? 242 244 \\ORDER BY indexed_at ··· 278 280 conn.exec( 279 281 \\INSERT OR REPLACE INTO documents 280 282 \\(uri, did, rkey, title, content, created_at, publication_uri, 281 - \\ platform, source_collection, path, base_path, has_publication, indexed_at, embedded_at) 282 - \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 283 + \\ platform, source_collection, path, base_path, has_publication, indexed_at, embedded_at, cover_image) 284 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 283 285 , .{ 284 286 row.text(0), // uri 285 287 row.text(1), // did ··· 295 297 row.int(11), // has_publication 296 298 row.text(12), // indexed_at 297 299 row.text(13), // embedded_at 300 + row.text(14), // cover_image 298 301 }) catch |err| { 299 302 return err; 300 303 };
+300
scripts/backfill-cover-images
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["httpx", "pydantic-settings"] 5 + # /// 6 + """ 7 + Backfill cover_image column for existing documents. 8 + 9 + Fetches records from their PDS and extracts coverImage blob CIDs. 10 + For leaflet documents, falls back to the first image block CID. 11 + 12 + Usage: 13 + ./scripts/backfill-cover-images # all platforms 14 + ./scripts/backfill-cover-images --platform pckt # specific platform 15 + ./scripts/backfill-cover-images --dry-run # preview only 16 + ./scripts/backfill-cover-images --limit 50 # limit batch size 17 + """ 18 + 19 + import argparse 20 + import os 21 + import sys 22 + import time 23 + 24 + import httpx 25 + from pydantic_settings import BaseSettings, SettingsConfigDict 26 + 27 + 28 + class Settings(BaseSettings): 29 + model_config = SettingsConfigDict( 30 + env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 31 + ) 32 + turso_url: str 33 + turso_token: str 34 + 35 + @property 36 + def turso_host(self) -> str: 37 + url = self.turso_url 38 + if url.startswith("libsql://"): 39 + url = url[len("libsql://"):] 40 + return url 41 + 42 + 43 + def turso_query(settings: Settings, sql: str, args: list | None = None) -> list[dict]: 44 + stmt: dict = {"sql": sql} 45 + if args: 46 + stmt["args"] = [] 47 + for a in args: 48 + if a is None: 49 + stmt["args"].append({"type": "null"}) 50 + else: 51 + stmt["args"].append({"type": "text", "value": str(a)}) 52 + 53 + response = httpx.post( 54 + f"https://{settings.turso_host}/v2/pipeline", 55 + headers={ 56 + "Authorization": f"Bearer {settings.turso_token}", 57 + "Content-Type": "application/json", 58 + }, 59 + json={"requests": [{"type": "execute", "stmt": stmt}, {"type": "close"}]}, 60 + timeout=30, 61 + ) 62 + response.raise_for_status() 63 + data = response.json() 64 + 65 + result = data["results"][0] 66 + if result.get("type") == "error": 67 + raise RuntimeError(f"turso error: {result['error']}") 68 + 69 + resp = result["response"]["result"] 70 + cols = [c["name"] for c in resp["cols"]] 71 + rows = [] 72 + for row in resp["rows"]: 73 + rows.append({cols[i]: cell["value"] if cell["type"] != "null" else None for i, cell in enumerate(row)}) 74 + return rows 75 + 76 + 77 + def turso_exec(settings: Settings, sql: str, args: list | None = None) -> None: 78 + stmt: dict = {"sql": sql} 79 + if args: 80 + stmt["args"] = [] 81 + for a in args: 82 + if a is None: 83 + stmt["args"].append({"type": "null"}) 84 + else: 85 + stmt["args"].append({"type": "text", "value": str(a)}) 86 + 87 + response = httpx.post( 88 + f"https://{settings.turso_host}/v2/pipeline", 89 + headers={ 90 + "Authorization": f"Bearer {settings.turso_token}", 91 + "Content-Type": "application/json", 92 + }, 93 + json={"requests": [{"type": "execute", "stmt": stmt}, {"type": "close"}]}, 94 + timeout=30, 95 + ) 96 + if response.status_code != 200: 97 + print(f"turso error: {response.text}", file=sys.stderr) 98 + response.raise_for_status() 99 + 100 + 101 + # --- PDS helpers --- 102 + 103 + _pds_cache: dict[str, str] = {} 104 + 105 + 106 + def get_pds_endpoint(did: str) -> str | None: 107 + if did in _pds_cache: 108 + return _pds_cache[did] 109 + try: 110 + resp = httpx.get(f"https://plc.directory/{did}", timeout=10) 111 + resp.raise_for_status() 112 + data = resp.json() 113 + for service in data.get("service", []): 114 + if service.get("type") == "AtprotoPersonalDataServer": 115 + _pds_cache[did] = service["serviceEndpoint"] 116 + return _pds_cache[did] 117 + except Exception as e: 118 + print(f" warning: failed to resolve PDS for {did}: {e}", file=sys.stderr) 119 + return None 120 + 121 + 122 + def get_record(pds: str, did: str, collection: str, rkey: str) -> dict | None: 123 + try: 124 + resp = httpx.get( 125 + f"{pds}/xrpc/com.atproto.repo.getRecord", 126 + params={"repo": did, "collection": collection, "rkey": rkey}, 127 + timeout=10, 128 + ) 129 + if resp.status_code in (400, 404): 130 + return None 131 + resp.raise_for_status() 132 + return resp.json().get("value") 133 + except Exception as e: 134 + print(f" warning: failed to fetch {collection}/{rkey}: {e}", file=sys.stderr) 135 + return None 136 + 137 + 138 + # platform-native collections to try when source_collection fails 139 + FALLBACK_COLLECTIONS: dict[str, list[str]] = { 140 + "pckt": ["blog.pckt.document"], 141 + "offprint": ["app.offprint.document.article"], 142 + "greengale": ["app.greengale.document"], 143 + "leaflet": ["pub.leaflet.document"], 144 + } 145 + 146 + 147 + def get_record_with_fallbacks(pds: str, did: str, collection: str, rkey: str, platform: str) -> dict | None: 148 + """Try source_collection first, then platform-native collections.""" 149 + value = get_record(pds, did, collection, rkey) 150 + if value: 151 + return value 152 + for alt in FALLBACK_COLLECTIONS.get(platform, []): 153 + if alt != collection: 154 + value = get_record(pds, did, alt, rkey) 155 + if value: 156 + return value 157 + return None 158 + 159 + 160 + # --- cover image extraction (mirrors extractor.zig logic) --- 161 + 162 + def extract_cover_image(value: dict) -> str | None: 163 + """Extract cover image CID from a record value.""" 164 + # try coverImage.ref.$link (site.standard/pckt/offprint/greengale) 165 + cover = value.get("coverImage") 166 + if isinstance(cover, dict): 167 + ref = cover.get("ref") 168 + if isinstance(ref, dict): 169 + link = ref.get("$link") 170 + if link: 171 + return link 172 + 173 + # fallback: first image block in leaflet pages 174 + return extract_first_image_cid(value) 175 + 176 + 177 + def extract_first_image_cid(value: dict) -> str | None: 178 + """Extract first image blob CID from leaflet page blocks.""" 179 + # pages at top level or nested in content 180 + pages = value.get("pages") 181 + if not isinstance(pages, list): 182 + content = value.get("content") 183 + if isinstance(content, dict): 184 + pages = content.get("pages") 185 + if not isinstance(pages, list): 186 + return None 187 + 188 + for page in pages: 189 + if not isinstance(page, dict): 190 + continue 191 + blocks = page.get("blocks", []) 192 + for wrapper in blocks: 193 + if not isinstance(wrapper, dict): 194 + continue 195 + block = wrapper.get("block", {}) 196 + if not isinstance(block, dict): 197 + continue 198 + if block.get("$type") == "pub.leaflet.blocks.image": 199 + image = block.get("image") 200 + if isinstance(image, dict): 201 + ref = image.get("ref") 202 + if isinstance(ref, dict): 203 + link = ref.get("$link") 204 + if link: 205 + return link 206 + return None 207 + 208 + 209 + def collection_for_platform(platform: str) -> str: 210 + return { 211 + "leaflet": "pub.leaflet.document", 212 + "pckt": "site.standard.document", 213 + "offprint": "site.standard.document", 214 + "greengale": "site.standard.document", 215 + "other": "site.standard.document", 216 + "whitewind": "com.whtwnd.blog.entry", 217 + }.get(platform, "site.standard.document") 218 + 219 + 220 + def main(): 221 + parser = argparse.ArgumentParser(description="Backfill cover_image for existing documents") 222 + parser.add_argument("--platform", help="Only backfill this platform") 223 + parser.add_argument("--limit", type=int, default=500, help="Max documents to process (default 500)") 224 + parser.add_argument("--dry-run", action="store_true", help="Preview without writing") 225 + args = parser.parse_args() 226 + 227 + try: 228 + settings = Settings() # type: ignore 229 + except Exception as e: 230 + print(f"error loading settings: {e}", file=sys.stderr) 231 + print("required env vars: TURSO_URL, TURSO_TOKEN", file=sys.stderr) 232 + sys.exit(1) 233 + 234 + # query docs missing cover_image 235 + where = "WHERE (cover_image IS NULL OR cover_image = '')" 236 + sql_args: list = [] 237 + if args.platform: 238 + where += " AND platform = ?" 239 + sql_args.append(args.platform) 240 + 241 + sql = f"SELECT uri, did, rkey, platform, source_collection, title FROM documents {where} LIMIT ?" 242 + sql_args.append(args.limit) 243 + 244 + print(f"querying documents missing cover_image...") 245 + docs = turso_query(settings, sql, sql_args) 246 + print(f"found {len(docs)} documents to check") 247 + 248 + if not docs: 249 + return 250 + 251 + updated = 0 252 + skipped = 0 253 + errors = 0 254 + 255 + for i, doc in enumerate(docs): 256 + did = doc["did"] 257 + rkey = doc["rkey"] 258 + platform = doc["platform"] 259 + collection = doc.get("source_collection") or collection_for_platform(platform) 260 + title = (doc.get("title") or "")[:50] 261 + 262 + # resolve PDS 263 + pds = get_pds_endpoint(did) 264 + if not pds: 265 + errors += 1 266 + continue 267 + 268 + # fetch record (try source_collection, then platform-native fallbacks) 269 + value = get_record_with_fallbacks(pds, did, collection, rkey, platform) 270 + if not value: 271 + skipped += 1 272 + continue 273 + 274 + # extract cover image 275 + cid = extract_cover_image(value) 276 + if not cid: 277 + skipped += 1 278 + continue 279 + 280 + if args.dry_run: 281 + print(f" [{i+1}/{len(docs)}] would set cover_image={cid[:20]}... for {title}...") 282 + else: 283 + turso_exec( 284 + settings, 285 + "UPDATE documents SET cover_image = ? WHERE uri = ?", 286 + [cid, doc["uri"]], 287 + ) 288 + print(f" [{i+1}/{len(docs)}] {title}... -> {cid[:20]}...") 289 + 290 + updated += 1 291 + 292 + # be nice to PDS servers 293 + if (i + 1) % 50 == 0: 294 + time.sleep(1) 295 + 296 + print(f"\ndone! {updated} updated, {skipped} skipped (no image), {errors} errors") 297 + 298 + 299 + if __name__ == "__main__": 300 + main()
+3 -21
site/dashboard.css
··· 229 229 .live span { color: #4ade80; } 230 230 231 231 .theme-toggle { 232 - display: inline-flex; 233 - gap: 0.25rem; 234 - } 235 - 236 - .theme-option { 237 - font-size: 10px; 238 - padding: 1px 5px; 239 - background: var(--bg-hover); 240 - border: 1px solid var(--border-subtle); 241 - border-radius: 3px; 242 232 cursor: pointer; 243 233 color: var(--text-dim); 244 - } 245 - 246 - .theme-option:hover { 247 - border-color: var(--border-focus); 248 - color: var(--text); 249 - } 250 - 251 - .theme-option.active { 252 - background: rgba(100, 100, 100, 0.3); 253 - border-color: var(--text-dim); 254 - color: var(--text-secondary); 234 + font-size: 16px; 235 + user-select: none; 255 236 } 237 + .theme-toggle:hover { color: var(--text); } 256 238 257 239 footer { 258 240 margin-top: 2rem;
+7 -3
site/dashboard.html
··· 99 99 document.documentElement.setAttribute('data-theme', resolved); 100 100 renderThemeToggle(); 101 101 } 102 + var THEME_ICONS = { dark: '\u263E', light: '\u263C', system: '\u25D1' }; 103 + var THEME_CYCLE = ['dark', 'light', 'system']; 104 + function cycleTheme() { 105 + var next = THEME_CYCLE[(THEME_CYCLE.indexOf(currentTheme) + 1) % 3]; 106 + applyTheme(next); 107 + } 102 108 function renderThemeToggle() { 103 109 var el = document.getElementById('theme-toggle'); 104 110 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(''); 111 + el.innerHTML = '<span onclick="cycleTheme()" title="' + currentTheme + '">' + THEME_ICONS[currentTheme] + '</span>'; 108 112 } 109 113 matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() { 110 114 if (currentTheme === 'system') applyTheme('system');
+42 -58
site/index.html
··· 159 159 } 160 160 161 161 .result { 162 + position: relative; 162 163 border-bottom: 1px solid var(--border); 163 164 padding: 1rem 0; 164 165 } 166 + 167 + .result.has-cover { padding-right: 52px; } 165 168 166 169 .result:hover { background: var(--result-hover); margin: 0 -0.5rem; padding: 1rem 0.5rem; } 170 + .result.has-cover:hover { padding-right: 52px; } 171 + .result:hover .cover-thumb { right: 1.25rem; } 167 172 168 173 .result-title { 169 174 color: var(--text-bright); ··· 238 243 background: rgba(180, 100, 64, 0.2); 239 244 color: #d4956a; 240 245 } 246 + .platform-badge:hover { background: rgba(180, 100, 64, 0.35); } 241 247 242 248 .status { 243 249 padding: 1rem; ··· 615 621 } 616 622 617 623 .theme-toggle { 618 - display: inline-flex; 619 - gap: 0.25rem; 620 - } 621 - 622 - .theme-option { 623 - font-size: 10px; 624 - padding: 1px 5px; 625 - background: var(--bg-hover); 626 - border: 1px solid var(--border-subtle); 627 - border-radius: 3px; 628 624 cursor: pointer; 629 625 color: var(--text-dim); 630 - } 631 - 632 - .theme-option:hover { 633 - border-color: var(--border-focus); 634 - color: var(--text); 626 + font-size: 16px; 627 + user-select: none; 635 628 } 636 - 637 - .theme-option.active { 638 - background: rgba(100, 100, 100, 0.3); 639 - border-color: var(--text-dim); 640 - color: var(--text-secondary); 641 - } 629 + .theme-toggle:hover { color: var(--text); } 642 630 643 631 .cover-thumb { 644 - width: 32px; 645 - height: 32px; 632 + position: absolute; 633 + top: 0.75rem; 634 + right: 0.75rem; 635 + width: 40px; 636 + height: 40px; 646 637 border-radius: 4px; 647 638 object-fit: cover; 648 - flex-shrink: 0; 649 - } 650 - 651 - .result-row { 652 - display: flex; 653 - gap: 0.75rem; 654 - align-items: flex-start; 655 - } 656 - 657 - .result-content { 658 - flex: 1; 659 - min-width: 0; 660 639 } 661 640 662 641 /* mobile improvements */ ··· 711 690 padding: 0.75rem; 712 691 } 713 692 693 + .result:hover .cover-thumb { right: 1.5rem; } 694 + 714 695 .result-title { 715 696 font-size: 14px; 716 697 line-height: 1.4; ··· 747 728 } 748 729 749 730 .cover-thumb { 750 - width: 28px; 751 - height: 28px; 731 + width: 32px; 732 + height: 32px; 752 733 } 734 + 735 + .result.has-cover { padding-right: 44px; } 736 + .result.has-cover:hover { padding-right: 44px; } 753 737 } 754 738 755 739 /* ensure touch targets on tablets too */ ··· 809 793 document.documentElement.setAttribute('data-theme', resolved); 810 794 renderThemeToggle(); 811 795 } 796 + const THEME_ICONS = { dark: '\u263E', light: '\u263C', system: '\u25D1' }; 797 + const THEME_CYCLE = ['dark', 'light', 'system']; 798 + function cycleTheme() { 799 + const next = THEME_CYCLE[(THEME_CYCLE.indexOf(currentTheme) + 1) % 3]; 800 + applyTheme(next); 801 + } 812 802 function renderThemeToggle() { 813 803 const el = document.getElementById('theme-toggle'); 814 804 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(''); 805 + el.innerHTML = `<span onclick="cycleTheme()" title="${currentTheme}">${THEME_ICONS[currentTheme]}</span>`; 818 806 } 819 807 matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { 820 808 if (currentTheme === 'system') applyTheme('system'); ··· 911 899 const docUrl = buildDocUrl(doc, entityType, plat); 912 900 const platformConfig = PLATFORM_CONFIG[plat]; 913 901 const platformBadge = platformConfig 914 - ? `<span class="platform-badge">${escapeHtml(platformConfig.label)}</span>` 902 + ? `<span class="platform-badge" onclick="event.stopPropagation(); setPlatform('${escapeHtml(plat)}')" style="cursor:pointer">${escapeHtml(platformConfig.label)}</span>` 915 903 : ''; 916 904 const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 917 905 const platformHome = getPlatformHome(plat, doc.basePath); ··· 934 922 : ''; 935 923 936 924 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> 925 + <div class="result${coverHtml ? ' has-cover' : ''}"> 926 + ${coverHtml} 927 + <div class="result-title"> 928 + <span class="entity-type ${entityType}">${entityType}</span>${platformBadge} 929 + ${docUrl 930 + ? `<a href="${docUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 931 + : escapeHtml(doc.title || 'Untitled')}${sourceBadge} 932 + </div> 933 + ${snippetHtml} 934 + <div class="result-meta" ${doc.did ? `data-did="${escapeHtml(doc.did)}"` : ''}> 935 + ${date ? `${date} | ` : ''}<span class="author-name"></span>${platformHome.url 936 + ? `<a href="${platformHome.url}" target="_blank">${platformHome.label}</a>` 937 + : platformHome.label} 954 938 </div> 955 939 </div> 956 940 `;