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

revert: remove all semantic search features, restore keyword-only

backend reverted to 6689407 state:
- server.zig: no mode param, just calls search.search()
- search.zig: remove searchSemantic, searchHybrid, embedQuery
- embedder.zig: back to voyage-3-lite, batch 20, 512 dims

frontend: keyword-only search, no semantic phase, no mode selector

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+29 -382
+13 -17
backend/src/ingest/embedder.zig
··· 12 const logfire = @import("logfire"); 13 const db = @import("../db/mod.zig"); 14 15 - // voyage-3 limits 16 - const MAX_BATCH_SIZE = 100; // voyage-3 supports up to 128 per request 17 const MAX_CONTENT_CHARS = 8000; // ~2000 tokens, well under 32K limit 18 - const EMBEDDING_DIM = 1024; 19 const POLL_INTERVAL_SECS: u64 = 60; // check for new docs every minute 20 const ERROR_BACKOFF_SECS: u64 = 300; // 5 min backoff on errors 21 22 - const NUM_WORKERS = 1; 23 - 24 - /// Start the embedder background workers 25 pub fn start(allocator: Allocator) void { 26 const api_key = posix.getenv("VOYAGE_API_KEY") orelse { 27 logfire.info("embedder: VOYAGE_API_KEY not set, embeddings disabled", .{}); 28 return; 29 }; 30 31 - for (0..NUM_WORKERS) |i| { 32 - const thread = std.Thread.spawn(.{}, worker, .{ allocator, api_key }) catch |err| { 33 - logfire.err("embedder: failed to start worker {d}: {}", .{ i, err }); 34 - continue; 35 - }; 36 - thread.detach(); 37 - } 38 - logfire.info("embedder: {d} background workers started", .{NUM_WORKERS}); 39 } 40 41 fn worker(allocator: Allocator, api_key: []const u8) void { ··· 77 78 const client = db.getClient() orelse return error.NoClient; 79 80 - // query for documents needing embeddings (RANDOM order for parallel workers) 81 var result = try client.query( 82 - "SELECT uri, title, content FROM documents WHERE embedding IS NULL ORDER BY RANDOM() LIMIT ?", 83 &.{std.fmt.comptimePrint("{}", .{MAX_BATCH_SIZE})}, 84 ); 85 defer result.deinit(); ··· 220 try jw.beginObject(); 221 222 try jw.objectField("model"); 223 - try jw.write("voyage-3"); 224 225 try jw.objectField("input_type"); 226 try jw.write("document");
··· 12 const logfire = @import("logfire"); 13 const db = @import("../db/mod.zig"); 14 15 + // voyage-3-lite limits 16 + const MAX_BATCH_SIZE = 20; // conservative batch size for reliability 17 const MAX_CONTENT_CHARS = 8000; // ~2000 tokens, well under 32K limit 18 + const EMBEDDING_DIM = 512; 19 const POLL_INTERVAL_SECS: u64 = 60; // check for new docs every minute 20 const ERROR_BACKOFF_SECS: u64 = 300; // 5 min backoff on errors 21 22 + /// Start the embedder background worker 23 pub fn start(allocator: Allocator) void { 24 const api_key = posix.getenv("VOYAGE_API_KEY") orelse { 25 logfire.info("embedder: VOYAGE_API_KEY not set, embeddings disabled", .{}); 26 return; 27 }; 28 29 + const thread = std.Thread.spawn(.{}, worker, .{ allocator, api_key }) catch |err| { 30 + logfire.err("embedder: failed to start thread: {}", .{err}); 31 + return; 32 + }; 33 + thread.detach(); 34 + logfire.info("embedder: background worker started", .{}); 35 } 36 37 fn worker(allocator: Allocator, api_key: []const u8) void { ··· 73 74 const client = db.getClient() orelse return error.NoClient; 75 76 + // query for documents needing embeddings 77 var result = try client.query( 78 + "SELECT uri, title, content FROM documents WHERE embedding IS NULL LIMIT ?", 79 &.{std.fmt.comptimePrint("{}", .{MAX_BATCH_SIZE})}, 80 ); 81 defer result.deinit(); ··· 216 try jw.beginObject(); 217 218 try jw.objectField("model"); 219 + try jw.write("voyage-3-lite"); 220 221 try jw.objectField("input_type"); 222 try jw.write("document");
-228
backend/src/search.zig
··· 1 const std = @import("std"); 2 - const http = std.http; 3 const json = std.json; 4 - const posix = std.posix; 5 const Allocator = std.mem.Allocator; 6 const zql = @import("zql"); 7 - const logfire = @import("logfire"); 8 const db = @import("db/mod.zig"); 9 const stats = @import("metrics.zig").stats; 10 ··· 798 } 799 buf[pos] = '*'; 800 return buf; 801 - } 802 - 803 - // --- semantic search --- 804 - 805 - const EMBEDDING_DIM = 1024; 806 - 807 - /// Embed a query string via Voyage AI, returns JSON array string for vector32() 808 - fn embedQuery(alloc: Allocator, query: []const u8) ![]const u8 { 809 - const api_key = posix.getenv("VOYAGE_API_KEY") orelse return error.NoApiKey; 810 - 811 - var http_client: http.Client = .{ .allocator = alloc }; 812 - defer http_client.deinit(); 813 - 814 - // build request: single query, input_type "query" 815 - var body: std.Io.Writer.Allocating = .init(alloc); 816 - var jw: json.Stringify = .{ .writer = &body.writer, .options = .{} }; 817 - try jw.beginObject(); 818 - try jw.objectField("model"); 819 - try jw.write("voyage-3"); 820 - try jw.objectField("input_type"); 821 - try jw.write("query"); 822 - try jw.objectField("input"); 823 - try jw.beginArray(); 824 - try jw.write(query); 825 - try jw.endArray(); 826 - try jw.endObject(); 827 - const request_body = try body.toOwnedSlice(); 828 - defer alloc.free(request_body); 829 - 830 - var auth_buf: [256]u8 = undefined; 831 - const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{api_key}) catch 832 - return error.AuthTooLong; 833 - 834 - var response_body: std.Io.Writer.Allocating = .init(alloc); 835 - const res = http_client.fetch(.{ 836 - .location = .{ .url = "https://api.voyageai.com/v1/embeddings" }, 837 - .method = .POST, 838 - .headers = .{ 839 - .content_type = .{ .override = "application/json" }, 840 - .authorization = .{ .override = auth }, 841 - }, 842 - .payload = request_body, 843 - .response_writer = &response_body.writer, 844 - }) catch return error.VoyageRequestFailed; 845 - 846 - if (res.status != .ok) { 847 - response_body.deinit(); 848 - return error.VoyageApiError; 849 - } 850 - 851 - const response_text = try response_body.toOwnedSlice(); 852 - defer alloc.free(response_text); 853 - 854 - // parse response, extract first embedding, format as JSON array for vector32() 855 - const parsed = json.parseFromSlice(json.Value, alloc, response_text, .{}) catch 856 - return error.ParseError; 857 - defer parsed.deinit(); 858 - 859 - const data = parsed.value.object.get("data") orelse return error.MissingData; 860 - if (data != .array or data.array.items.len == 0) return error.InvalidData; 861 - 862 - const embedding_obj = data.array.items[0]; 863 - if (embedding_obj != .object) return error.InvalidData; 864 - const embedding_val = embedding_obj.object.get("embedding") orelse return error.MissingEmbedding; 865 - if (embedding_val != .array or embedding_val.array.items.len != EMBEDDING_DIM) return error.DimensionMismatch; 866 - 867 - // format as "[0.123456,0.789012,...]" 868 - var embedding_buf: std.ArrayList(u8) = .empty; 869 - errdefer embedding_buf.deinit(alloc); 870 - 871 - try embedding_buf.append(alloc, '['); 872 - for (embedding_val.array.items, 0..) |val, j| { 873 - if (j > 0) try embedding_buf.append(alloc, ','); 874 - const f: f64 = switch (val) { 875 - .float => val.float, 876 - .integer => @floatFromInt(val.integer), 877 - else => return error.InvalidValue, 878 - }; 879 - var fmt_buf: [32]u8 = undefined; 880 - const str = std.fmt.bufPrint(&fmt_buf, "{d:.6}", .{f}) catch continue; 881 - try embedding_buf.appendSlice(alloc, str); 882 - } 883 - try embedding_buf.append(alloc, ']'); 884 - 885 - return try alloc.dupe(u8, embedding_buf.items); 886 - } 887 - 888 - /// Semantic search: embed query via Voyage AI, find similar docs by vector distance 889 - pub fn searchSemantic(alloc: Allocator, query: []const u8, platform_filter: ?[]const u8) ![]const u8 { 890 - const span = logfire.span("search.semantic", .{ .query = query }); 891 - defer span.end(); 892 - 893 - const embedding_json = try embedQuery(alloc, query); 894 - defer alloc.free(embedding_json); 895 - 896 - const c = db.getClient() orelse return error.NotInitialized; 897 - 898 - var output: std.Io.Writer.Allocating = .init(alloc); 899 - errdefer output.deinit(); 900 - var jw: json.Stringify = .{ .writer = &output.writer }; 901 - try jw.beginArray(); 902 - 903 - if (platform_filter) |platform| { 904 - var res = c.query( 905 - \\SELECT d.uri, d.did, d.title, '' as snippet, 906 - \\ d.created_at, d.rkey, d.base_path, d.has_publication, 907 - \\ d.platform, COALESCE(d.path, '') as path 908 - \\FROM documents d 909 - \\WHERE d.embedding IS NOT NULL AND d.platform = ? 910 - \\ORDER BY vector_distance_cos(vector32(?), d.embedding) 911 - \\LIMIT 20 912 - , &.{ platform, embedding_json }) catch { 913 - try jw.endArray(); 914 - return try output.toOwnedSlice(); 915 - }; 916 - defer res.deinit(); 917 - for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 918 - } else { 919 - var res = c.query( 920 - \\SELECT d.uri, d.did, d.title, '' as snippet, 921 - \\ d.created_at, d.rkey, d.base_path, d.has_publication, 922 - \\ d.platform, COALESCE(d.path, '') as path 923 - \\FROM documents d 924 - \\WHERE d.embedding IS NOT NULL 925 - \\ORDER BY vector_distance_cos(vector32(?), d.embedding) 926 - \\LIMIT 20 927 - , &.{embedding_json}) catch { 928 - try jw.endArray(); 929 - return try output.toOwnedSlice(); 930 - }; 931 - defer res.deinit(); 932 - for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 933 - } 934 - 935 - try jw.endArray(); 936 - return try output.toOwnedSlice(); 937 - } 938 - 939 - /// Hybrid search: keyword results + semantic results, deduplicated 940 - pub fn searchHybrid(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8, platform_filter: ?[]const u8, since_filter: ?[]const u8) ![]const u8 { 941 - // embed query (slow: ~100-200ms Voyage API call) 942 - const embedding_json = embedQuery(alloc, query) catch { 943 - // fall back to keyword-only if embedding fails 944 - return search(alloc, query, tag_filter, platform_filter, since_filter); 945 - }; 946 - defer alloc.free(embedding_json); 947 - 948 - // keyword search (fast: local SQLite) 949 - const keyword_json = try search(alloc, query, tag_filter, platform_filter, since_filter); 950 - 951 - // vector search (Turso) 952 - const c = db.getClient() orelse return keyword_json; 953 - var vector_res = blk: { 954 - if (platform_filter) |platform| { 955 - break :blk c.query( 956 - \\SELECT d.uri, d.did, d.title, '' as snippet, 957 - \\ d.created_at, d.rkey, d.base_path, d.has_publication, 958 - \\ d.platform, COALESCE(d.path, '') as path 959 - \\FROM documents d 960 - \\WHERE d.embedding IS NOT NULL AND d.platform = ? 961 - \\ORDER BY vector_distance_cos(vector32(?), d.embedding) 962 - \\LIMIT 10 963 - , &.{ platform, embedding_json }) catch return keyword_json; 964 - } else { 965 - break :blk c.query( 966 - \\SELECT d.uri, d.did, d.title, '' as snippet, 967 - \\ d.created_at, d.rkey, d.base_path, d.has_publication, 968 - \\ d.platform, COALESCE(d.path, '') as path 969 - \\FROM documents d 970 - \\WHERE d.embedding IS NOT NULL 971 - \\ORDER BY vector_distance_cos(vector32(?), d.embedding) 972 - \\LIMIT 10 973 - , &.{embedding_json}) catch return keyword_json; 974 - } 975 - }; 976 - defer vector_res.deinit(); 977 - 978 - // extract URIs from keyword results for dedup 979 - var seen = std.StringHashMap(void).init(alloc); 980 - defer seen.deinit(); 981 - 982 - const kw = json.parseFromSlice(json.Value, alloc, keyword_json, .{}) catch return keyword_json; 983 - defer kw.deinit(); 984 - if (kw.value != .array) return keyword_json; 985 - 986 - for (kw.value.array.items) |item| { 987 - if (item == .object) { 988 - if (item.object.get("uri")) |u| { 989 - if (u == .string) seen.put(u.string, {}) catch {}; 990 - } 991 - } 992 - } 993 - 994 - // check if there are any new semantic results 995 - var has_new = false; 996 - for (vector_res.rows) |row| { 997 - if (!seen.contains(row.text(0))) { 998 - has_new = true; 999 - break; 1000 - } 1001 - } 1002 - if (!has_new) return keyword_json; 1003 - 1004 - // merge: keyword results + new semantic results 1005 - var output: std.Io.Writer.Allocating = .init(alloc); 1006 - errdefer output.deinit(); 1007 - var merge_jw: json.Stringify = .{ .writer = &output.writer }; 1008 - try merge_jw.beginArray(); 1009 - 1010 - // write keyword results as raw JSON (avoids re-serialization) 1011 - if (kw.value.array.items.len > 0) { 1012 - try merge_jw.beginWriteRaw(); 1013 - try merge_jw.writer.writeAll(keyword_json[1 .. keyword_json.len - 1]); 1014 - merge_jw.endWriteRaw(); 1015 - } 1016 - 1017 - // append new semantic results 1018 - for (vector_res.rows) |row| { 1019 - if (!seen.contains(row.text(0))) { 1020 - try merge_jw.write(Doc.fromRow(row).toJson()); 1021 - } 1022 - } 1023 - 1024 - try merge_jw.endArray(); 1025 - return try output.toOwnedSlice(); 1026 } 1027 1028 // --- tests ---
··· 1 const std = @import("std"); 2 const json = std.json; 3 const Allocator = std.mem.Allocator; 4 const zql = @import("zql"); 5 const db = @import("db/mod.zig"); 6 const stats = @import("metrics.zig").stats; 7 ··· 795 } 796 buf[pos] = '*'; 797 return buf; 798 } 799 800 // --- tests ---
+6 -35
backend/src/server.zig
··· 85 const tag_filter = parseQueryParam(alloc, target, "tag") catch null; 86 const platform_filter = parseQueryParam(alloc, target, "platform") catch null; 87 const since_filter = parseQueryParam(alloc, target, "since") catch null; 88 - const mode = parseQueryParam(alloc, target, "mode") catch "keyword"; 89 90 const span = logfire.span("http.search", .{ 91 .query = query, 92 .tag = tag_filter, 93 .platform = platform_filter, 94 - .mode = mode, 95 }); 96 defer span.end(); 97 ··· 100 return; 101 } 102 103 - const results = blk: { 104 - if (mem.eql(u8, mode, "semantic")) { 105 - if (query.len == 0) { 106 - try sendJson(request, "{\"error\":\"semantic search requires a query\"}"); 107 - return; 108 - } 109 - break :blk search.searchSemantic(alloc, query, platform_filter) catch |err| { 110 - logfire.err("semantic search failed: {}", .{err}); 111 - metrics.stats.recordError(); 112 - return err; 113 - }; 114 - } else if (mem.eql(u8, mode, "hybrid")) { 115 - if (query.len == 0) { 116 - // hybrid without query falls back to keyword (tag/platform browsing) 117 - break :blk search.search(alloc, query, tag_filter, platform_filter, since_filter) catch |err| { 118 - logfire.err("search failed: {}", .{err}); 119 - metrics.stats.recordError(); 120 - return err; 121 - }; 122 - } 123 - break :blk search.searchHybrid(alloc, query, tag_filter, platform_filter, since_filter) catch |err| { 124 - logfire.err("hybrid search failed: {}", .{err}); 125 - metrics.stats.recordError(); 126 - return err; 127 - }; 128 - } else { 129 - break :blk search.search(alloc, query, tag_filter, platform_filter, since_filter) catch |err| { 130 - logfire.err("search failed: {}", .{err}); 131 - metrics.stats.recordError(); 132 - return err; 133 - }; 134 - } 135 }; 136 - 137 metrics.stats.recordSearch(query); 138 logfire.counter("search.requests", 1); 139 try sendJson(request, results);
··· 85 const tag_filter = parseQueryParam(alloc, target, "tag") catch null; 86 const platform_filter = parseQueryParam(alloc, target, "platform") catch null; 87 const since_filter = parseQueryParam(alloc, target, "since") catch null; 88 89 + // span attributes are now copied internally, safe to use arena strings 90 const span = logfire.span("http.search", .{ 91 .query = query, 92 .tag = tag_filter, 93 .platform = platform_filter, 94 }); 95 defer span.end(); 96 ··· 99 return; 100 } 101 102 + // perform FTS search - arena handles cleanup 103 + const results = search.search(alloc, query, tag_filter, platform_filter, since_filter) catch |err| { 104 + logfire.err("search failed: {}", .{err}); 105 + metrics.stats.recordError(); 106 + return err; 107 }; 108 metrics.stats.recordSearch(query); 109 logfire.counter("search.requests", 1); 110 try sendJson(request, results);
+10 -102
site/index.html
··· 371 color: #555; 372 } 373 374 - .mode-selector { 375 - margin-bottom: 1rem; 376 - } 377 - 378 - .mode-selector-label { 379 - font-size: 11px; 380 - color: #444; 381 - margin-bottom: 0.5rem; 382 - } 383 - 384 - .mode-list { 385 - display: flex; 386 - gap: 0.5rem; 387 - } 388 - 389 - .mode-option { 390 - font-size: 11px; 391 - padding: 3px 8px; 392 - background: #151515; 393 - border: 1px solid #252525; 394 - border-radius: 3px; 395 - cursor: pointer; 396 - color: #777; 397 - } 398 - 399 - .mode-option:hover { 400 - background: #1a1a1a; 401 - border-color: #333; 402 - color: #aaa; 403 - } 404 - 405 - .mode-option.active { 406 - background: rgba(100, 100, 200, 0.2); 407 - border-color: #6a6ad4; 408 - color: #8a8ae4; 409 - } 410 - 411 - .mode-option .mode-desc { 412 - font-size: 9px; 413 - color: #555; 414 - margin-left: 4px; 415 - } 416 - 417 - .mode-option.active .mode-desc { 418 - color: #6a6ad4; 419 - } 420 - 421 .platform-filter { 422 margin-bottom: 1rem; 423 } ··· 489 } 490 491 /* ensure minimum 44px touch targets */ 492 - .tag, .platform-option, .mode-option, .suggestion, input.tag-input { 493 min-height: 44px; 494 display: inline-flex; 495 align-items: center; ··· 567 568 /* ensure touch targets on tablets too */ 569 @media (hover: none) and (pointer: coarse) { 570 - .tag, .platform-option, .mode-option, .suggestion, .related-item, input.tag-input { 571 min-height: 44px; 572 display: inline-flex; 573 align-items: center; ··· 583 <input type="text" id="query" placeholder="search content..." autofocus> 584 <button id="search-btn">search</button> 585 </div> 586 - 587 - <div id="mode-selector" class="mode-selector"></div> 588 589 <div id="suggestions"></div> 590 ··· 617 const activeFilterDiv = document.getElementById('active-filter'); 618 const suggestionsDiv = document.getElementById('suggestions'); 619 const platformFilterDiv = document.getElementById('platform-filter'); 620 - const modeSelectorDiv = document.getElementById('mode-selector'); 621 622 let currentTag = null; 623 let currentPlatform = null; 624 - let currentMode = 'hybrid'; 625 let allTags = []; 626 let popularSearches = []; 627 const authorCache = new Map(); 628 - 629 async function search(query, tag = null, platform = null) { 630 if (!query.trim() && !tag && !platform) return; 631 632 searchBtn.disabled = true; 633 let searchUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}`; 634 if (tag) searchUrl += `&tag=${encodeURIComponent(tag)}`; 635 if (platform) searchUrl += `&platform=${encodeURIComponent(platform)}`; 636 - if (currentMode !== 'hybrid') searchUrl += `&mode=${currentMode}`; 637 - 638 - const modeLabel = currentMode === 'semantic' ? 'finding similar...' 639 - : currentMode === 'hybrid' ? 'searching + embedding...' 640 - : 'searching...'; 641 - resultsDiv.innerHTML = `<div class="status">${modeLabel}</div>`; 642 643 try { 644 const res = await fetch(searchUrl); ··· 661 resultsDiv.innerHTML = ` 662 <div class="empty-state"> 663 <p>no results${query ? ` for ${formatQueryForDisplay(query)}` : ''}${tag ? ` in #${escapeHtml(tag)}` : ''}${platform ? ` on ${escapeHtml(platform)}` : ''}</p> 664 - <p>try different keywords</p> 665 </div> 666 `; 667 statsDiv.textContent = ''; ··· 669 } 670 671 let html = ''; 672 - 673 for (const doc of results) { 674 const entityType = doc.type || 'article'; 675 - const platform = doc.platform || 'leaflet'; 676 - 677 - // build URL based on entity type and platform 678 - const docUrl = buildDocUrl(doc, entityType, platform); 679 - // only show platform badge for actual platforms, not for lexicon-only records 680 - const platformConfig = PLATFORM_CONFIG[platform]; 681 const platformBadge = platformConfig 682 ? `<span class="platform-badge">${escapeHtml(platformConfig.label)}</span>` 683 : ''; 684 const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 685 - 686 - // platform home URL for meta link 687 - const platformHome = getPlatformHome(platform, doc.basePath); 688 689 html += ` 690 <div class="result"> ··· 706 707 resultsDiv.innerHTML = html; 708 statsDiv.textContent = `${results.length} result${results.length === 1 ? '' : 's'}`; 709 - 710 - // resolve author display names (non-blocking) 711 resolveAuthors(results); 712 713 - // load related documents based on top result (skip for semantic/hybrid - already using embeddings) 714 - if (currentMode === 'keyword' && results.length > 0 && results[0].uri) { 715 loadRelated(results[0]); 716 } 717 718 } catch (err) { 719 - resultsDiv.innerHTML = `<div class="status error">error: ${err.message}<br><code style="font-size:0.7rem">${searchUrl}</code></div>`; 720 } finally { 721 searchBtn.disabled = false; 722 } ··· 900 if (q) params.set('q', q); 901 if (currentTag) params.set('tag', currentTag); 902 if (currentPlatform) params.set('platform', currentPlatform); 903 - if (currentMode !== 'hybrid') params.set('mode', currentMode); 904 const url = params.toString() ? `?${params}` : '/'; 905 history.pushState(null, '', url); 906 } ··· 956 } 957 } 958 959 - function setMode(mode) { 960 - currentMode = mode; 961 - renderModeSelector(); 962 - if (queryInput.value.trim() || currentTag || currentPlatform) { 963 - doSearch(); 964 - } 965 - } 966 - 967 - function renderModeSelector() { 968 - const modes = [ 969 - { id: 'keyword', label: 'keyword', desc: 'BM25' }, 970 - { id: 'semantic', label: 'semantic', desc: 'embeddings' }, 971 - { id: 'hybrid', label: 'hybrid', desc: 'both' }, 972 - ]; 973 - const html = modes.map(m => ` 974 - <span class="mode-option${currentMode === m.id ? ' active' : ''}" onclick="setMode('${m.id}')">${m.label}<span class="mode-desc">${m.desc}</span></span> 975 - `).join(''); 976 - modeSelectorDiv.innerHTML = `<div class="mode-selector-label">search mode:</div><div class="mode-list">${html}</div>`; 977 - } 978 - 979 function renderPlatformFilter() { 980 const platforms = [ 981 { id: 'leaflet', label: 'leaflet' }, ··· 1096 queryInput.value = params.get('q') || ''; 1097 currentTag = params.get('tag') || null; 1098 currentPlatform = params.get('platform') || null; 1099 - currentMode = params.get('mode') || 'hybrid'; 1100 renderActiveFilter(); 1101 renderTags(); 1102 renderPlatformFilter(); 1103 - renderModeSelector(); 1104 if (queryInput.value || currentTag || currentPlatform) search(queryInput.value, currentTag, currentPlatform); 1105 }); 1106 ··· 1109 const initialQuery = initialParams.get('q'); 1110 const initialTag = initialParams.get('tag'); 1111 const initialPlatform = initialParams.get('platform'); 1112 - const initialMode = initialParams.get('mode'); 1113 if (initialQuery) queryInput.value = initialQuery; 1114 if (initialTag) currentTag = initialTag; 1115 if (initialPlatform) currentPlatform = initialPlatform; 1116 - if (initialMode) currentMode = initialMode; 1117 renderActiveFilter(); 1118 renderPlatformFilter(); 1119 - renderModeSelector(); 1120 1121 if (initialQuery || initialTag || initialPlatform) { 1122 search(initialQuery || '', initialTag, initialPlatform);
··· 371 color: #555; 372 } 373 374 .platform-filter { 375 margin-bottom: 1rem; 376 } ··· 442 } 443 444 /* ensure minimum 44px touch targets */ 445 + .tag, .platform-option, .suggestion, input.tag-input { 446 min-height: 44px; 447 display: inline-flex; 448 align-items: center; ··· 520 521 /* ensure touch targets on tablets too */ 522 @media (hover: none) and (pointer: coarse) { 523 + .tag, .platform-option, .suggestion, .related-item, input.tag-input { 524 min-height: 44px; 525 display: inline-flex; 526 align-items: center; ··· 536 <input type="text" id="query" placeholder="search content..." autofocus> 537 <button id="search-btn">search</button> 538 </div> 539 540 <div id="suggestions"></div> 541 ··· 568 const activeFilterDiv = document.getElementById('active-filter'); 569 const suggestionsDiv = document.getElementById('suggestions'); 570 const platformFilterDiv = document.getElementById('platform-filter'); 571 572 let currentTag = null; 573 let currentPlatform = null; 574 let allTags = []; 575 let popularSearches = []; 576 const authorCache = new Map(); 577 async function search(query, tag = null, platform = null) { 578 if (!query.trim() && !tag && !platform) return; 579 580 searchBtn.disabled = true; 581 + resultsDiv.innerHTML = `<div class="status">searching...</div>`; 582 + 583 let searchUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}`; 584 if (tag) searchUrl += `&tag=${encodeURIComponent(tag)}`; 585 if (platform) searchUrl += `&platform=${encodeURIComponent(platform)}`; 586 587 try { 588 const res = await fetch(searchUrl); ··· 605 resultsDiv.innerHTML = ` 606 <div class="empty-state"> 607 <p>no results${query ? ` for ${formatQueryForDisplay(query)}` : ''}${tag ? ` in #${escapeHtml(tag)}` : ''}${platform ? ` on ${escapeHtml(platform)}` : ''}</p> 608 </div> 609 `; 610 statsDiv.textContent = ''; ··· 612 } 613 614 let html = ''; 615 for (const doc of results) { 616 const entityType = doc.type || 'article'; 617 + const plat = doc.platform || 'leaflet'; 618 + const docUrl = buildDocUrl(doc, entityType, plat); 619 + const platformConfig = PLATFORM_CONFIG[plat]; 620 const platformBadge = platformConfig 621 ? `<span class="platform-badge">${escapeHtml(platformConfig.label)}</span>` 622 : ''; 623 const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 624 + const platformHome = getPlatformHome(plat, doc.basePath); 625 626 html += ` 627 <div class="result"> ··· 643 644 resultsDiv.innerHTML = html; 645 statsDiv.textContent = `${results.length} result${results.length === 1 ? '' : 's'}`; 646 resolveAuthors(results); 647 648 + if (results.length > 0 && results[0].uri) { 649 loadRelated(results[0]); 650 } 651 652 } catch (err) { 653 + resultsDiv.innerHTML = `<div class="status error">error: ${err.message}</div>`; 654 } finally { 655 searchBtn.disabled = false; 656 } ··· 834 if (q) params.set('q', q); 835 if (currentTag) params.set('tag', currentTag); 836 if (currentPlatform) params.set('platform', currentPlatform); 837 const url = params.toString() ? `?${params}` : '/'; 838 history.pushState(null, '', url); 839 } ··· 889 } 890 } 891 892 function renderPlatformFilter() { 893 const platforms = [ 894 { id: 'leaflet', label: 'leaflet' }, ··· 1009 queryInput.value = params.get('q') || ''; 1010 currentTag = params.get('tag') || null; 1011 currentPlatform = params.get('platform') || null; 1012 renderActiveFilter(); 1013 renderTags(); 1014 renderPlatformFilter(); 1015 if (queryInput.value || currentTag || currentPlatform) search(queryInput.value, currentTag, currentPlatform); 1016 }); 1017 ··· 1020 const initialQuery = initialParams.get('q'); 1021 const initialTag = initialParams.get('tag'); 1022 const initialPlatform = initialParams.get('platform'); 1023 if (initialQuery) queryInput.value = initialQuery; 1024 if (initialTag) currentTag = initialTag; 1025 if (initialPlatform) currentPlatform = initialPlatform; 1026 renderActiveFilter(); 1027 renderPlatformFilter(); 1028 1029 if (initialQuery || initialTag || initialPlatform) { 1030 search(initialQuery || '', initialTag, initialPlatform);