interactive intro to open social

feat: add handle autocomplete on landing page

search handles as you type using the Bluesky search API, showing
avatars, display names, and handles in a dropdown. selecting a
result navigates directly to that profile.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+7
Cargo.lock
··· 465 465 "serde", 466 466 "serde_json", 467 467 "tokio", 468 + "urlencoding", 468 469 ] 469 470 470 471 [[package]] ··· 3574 3575 "percent-encoding", 3575 3576 "serde", 3576 3577 ] 3578 + 3579 + [[package]] 3580 + name = "urlencoding" 3581 + version = "2.1.3" 3582 + source = "registry+https://github.com/rust-lang/crates.io-index" 3583 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 3577 3584 3578 3585 [[package]] 3579 3586 name = "utf-8"
+1
Cargo.toml
··· 26 26 once_cell = "1.20" 27 27 dashmap = "6.1" 28 28 chrono = { version = "0.4", features = ["serde"] } 29 + urlencoding = "2.1"
+2
src/constants.rs
··· 4 4 pub const BSKY_API_RESOLVE_HANDLE: &str = 5 5 "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"; 6 6 pub const BSKY_API_GET_PROFILE: &str = "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"; 7 + pub const BSKY_API_SEARCH_ACTORS: &str = 8 + "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors"; 7 9 pub const PLC_DIRECTORY: &str = "https://plc.directory"; 8 10 9 11 // Server Configuration
+1
src/main.rs
··· 51 51 .service(routes::init) 52 52 .service(routes::get_avatar) 53 53 .service(routes::get_avatar_batch) 54 + .service(routes::search_handles) 54 55 .service(routes::validate_url) 55 56 .service(routes::get_record) 56 57 .service(routes::auth_status)
+71
src/routes.rs
··· 608 608 } 609 609 610 610 #[derive(Deserialize)] 611 + pub struct SearchHandlesQuery { 612 + q: String, 613 + } 614 + 615 + #[derive(Serialize)] 616 + #[serde(rename_all = "camelCase")] 617 + pub struct HandleSearchResult { 618 + did: String, 619 + handle: String, 620 + display_name: String, 621 + avatar_url: Option<String>, 622 + } 623 + 624 + #[get("/api/search/handles")] 625 + pub async fn search_handles(query: web::Query<SearchHandlesQuery>) -> HttpResponse { 626 + let q = &query.q; 627 + 628 + if q.len() < 2 { 629 + return HttpResponse::Ok().json(serde_json::json!({ 630 + "results": [] 631 + })); 632 + } 633 + 634 + let search_url = format!( 635 + "{}?q={}&limit=8", 636 + constants::BSKY_API_SEARCH_ACTORS, 637 + urlencoding::encode(q) 638 + ); 639 + 640 + match http_get(&search_url).await { 641 + Ok(response) => match response.json::<serde_json::Value>().await { 642 + Ok(data) => { 643 + let results: Vec<HandleSearchResult> = data["actors"] 644 + .as_array() 645 + .map(|actors| { 646 + actors 647 + .iter() 648 + .map(|actor| HandleSearchResult { 649 + did: actor["did"].as_str().unwrap_or("").to_string(), 650 + handle: actor["handle"].as_str().unwrap_or("").to_string(), 651 + display_name: actor["displayName"] 652 + .as_str() 653 + .unwrap_or_else(|| actor["handle"].as_str().unwrap_or("")) 654 + .to_string(), 655 + avatar_url: actor["avatar"].as_str().map(String::from), 656 + }) 657 + .collect() 658 + }) 659 + .unwrap_or_default(); 660 + 661 + HttpResponse::Ok().json(serde_json::json!({ 662 + "results": results 663 + })) 664 + } 665 + Err(e) => { 666 + log::error!("Failed to parse search response: {}", e); 667 + HttpResponse::Ok().json(serde_json::json!({ 668 + "results": [] 669 + })) 670 + } 671 + }, 672 + Err(e) => { 673 + log::error!("Failed to search handles: {}", e); 674 + HttpResponse::Ok().json(serde_json::json!({ 675 + "results": [] 676 + })) 677 + } 678 + } 679 + } 680 + 681 + #[derive(Deserialize)] 611 682 pub struct AvatarQuery { 612 683 namespace: String, 613 684 }
+201 -1
src/templates/landing.html
··· 161 161 color: rgba(255, 255, 255, 0.3); 162 162 } 163 163 164 + .input-wrapper { 165 + position: relative; 166 + width: 100%; 167 + } 168 + 169 + .autocomplete-results { 170 + position: absolute; 171 + z-index: 100; 172 + width: 100%; 173 + max-height: 240px; 174 + overflow-y: auto; 175 + background: rgba(10, 10, 15, 0.98); 176 + border: 1px solid rgba(255, 255, 255, 0.2); 177 + border-radius: 4px; 178 + margin-top: 0.25rem; 179 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 180 + display: none; 181 + } 182 + 183 + .autocomplete-results.show { 184 + display: block; 185 + } 186 + 187 + .autocomplete-item { 188 + width: 100%; 189 + display: flex; 190 + align-items: center; 191 + gap: 0.75rem; 192 + padding: 0.75rem; 193 + background: transparent; 194 + border: none; 195 + border-bottom: 1px solid rgba(255, 255, 255, 0.1); 196 + color: #e5e5e5; 197 + text-align: left; 198 + font-family: inherit; 199 + cursor: pointer; 200 + transition: background 0.15s; 201 + } 202 + 203 + .autocomplete-item:last-child { 204 + border-bottom: none; 205 + } 206 + 207 + .autocomplete-item:hover { 208 + background: rgba(255, 255, 255, 0.1); 209 + } 210 + 211 + .autocomplete-avatar { 212 + width: 36px; 213 + height: 36px; 214 + border-radius: 50%; 215 + object-fit: cover; 216 + border: 1px solid rgba(255, 255, 255, 0.2); 217 + flex-shrink: 0; 218 + } 219 + 220 + .autocomplete-avatar-placeholder { 221 + width: 36px; 222 + height: 36px; 223 + border-radius: 50%; 224 + background: rgba(255, 255, 255, 0.1); 225 + flex-shrink: 0; 226 + display: flex; 227 + align-items: center; 228 + justify-content: center; 229 + font-size: 0.9rem; 230 + color: rgba(255, 255, 255, 0.5); 231 + } 232 + 233 + .autocomplete-info { 234 + flex: 1; 235 + min-width: 0; 236 + overflow: hidden; 237 + } 238 + 239 + .autocomplete-name { 240 + font-weight: 500; 241 + color: rgba(255, 255, 255, 0.9); 242 + margin-bottom: 0.125rem; 243 + overflow: hidden; 244 + text-overflow: ellipsis; 245 + white-space: nowrap; 246 + font-size: 0.85rem; 247 + } 248 + 249 + .autocomplete-handle { 250 + font-size: 0.75rem; 251 + color: rgba(255, 255, 255, 0.5); 252 + overflow: hidden; 253 + text-overflow: ellipsis; 254 + white-space: nowrap; 255 + } 256 + 257 + .search-spinner { 258 + position: absolute; 259 + right: 0.75rem; 260 + top: 50%; 261 + transform: translateY(-50%); 262 + color: rgba(255, 255, 255, 0.4); 263 + font-size: 0.75rem; 264 + } 265 + 164 266 button { 165 267 font-family: inherit; 166 268 font-size: 0.9rem; ··· 327 429 <h1>@me</h1> 328 430 <div class="subtitle">explore the atmosphere</div> 329 431 <form id="searchForm" onsubmit="event.preventDefault(); handleSearch();"> 330 - <input type="text" id="handleInput" placeholder="enter any handle" autofocus> 432 + <div class="input-wrapper"> 433 + <input type="text" id="handleInput" placeholder="enter any handle" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"> 434 + <span class="search-spinner" id="searchSpinner" style="display: none;">...</span> 435 + <div class="autocomplete-results" id="autocompleteResults"></div> 436 + </div> 331 437 <button type="submit">explore</button> 332 438 </form> 333 439 ··· 361 467 </div> 362 468 363 469 <script> 470 + // Autocomplete state 471 + let searchTimeout = null; 472 + let autocompleteResults = []; 473 + 364 474 function handleSearch() { 365 475 const handle = document.getElementById('handleInput').value.trim(); 366 476 if (handle) { ··· 375 485 function toggleInfo() { 376 486 document.getElementById('infoContent').classList.toggle('expanded'); 377 487 } 488 + 489 + // Autocomplete functionality 490 + const handleInput = document.getElementById('handleInput'); 491 + const resultsDiv = document.getElementById('autocompleteResults'); 492 + const spinner = document.getElementById('searchSpinner'); 493 + 494 + async function searchHandles(query) { 495 + if (query.length < 2) { 496 + autocompleteResults = []; 497 + hideResults(); 498 + return; 499 + } 500 + 501 + spinner.style.display = 'block'; 502 + 503 + try { 504 + const response = await fetch(`/api/search/handles?q=${encodeURIComponent(query)}`); 505 + if (response.ok) { 506 + const data = await response.json(); 507 + autocompleteResults = data.results; 508 + renderResults(); 509 + } 510 + } catch (e) { 511 + console.error('search failed:', e); 512 + } finally { 513 + spinner.style.display = 'none'; 514 + } 515 + } 516 + 517 + function renderResults() { 518 + if (autocompleteResults.length === 0) { 519 + hideResults(); 520 + return; 521 + } 522 + 523 + resultsDiv.innerHTML = autocompleteResults.map(result => ` 524 + <button type="button" class="autocomplete-item" onclick="selectHandle('${result.handle}')"> 525 + ${result.avatarUrl 526 + ? `<img src="${result.avatarUrl}" alt="" class="autocomplete-avatar">` 527 + : `<div class="autocomplete-avatar-placeholder">${result.handle[0].toUpperCase()}</div>` 528 + } 529 + <div class="autocomplete-info"> 530 + <div class="autocomplete-name">${escapeHtml(result.displayName)}</div> 531 + <div class="autocomplete-handle">@${escapeHtml(result.handle)}</div> 532 + </div> 533 + </button> 534 + `).join(''); 535 + 536 + resultsDiv.classList.add('show'); 537 + } 538 + 539 + function escapeHtml(text) { 540 + const div = document.createElement('div'); 541 + div.textContent = text; 542 + return div.innerHTML; 543 + } 544 + 545 + function hideResults() { 546 + resultsDiv.classList.remove('show'); 547 + } 548 + 549 + function selectHandle(handle) { 550 + handleInput.value = handle; 551 + autocompleteResults = []; 552 + hideResults(); 553 + viewHandle(handle); 554 + } 555 + 556 + handleInput.addEventListener('input', () => { 557 + if (searchTimeout) clearTimeout(searchTimeout); 558 + searchTimeout = setTimeout(() => searchHandles(handleInput.value), 300); 559 + }); 560 + 561 + handleInput.addEventListener('keydown', (e) => { 562 + if (e.key === 'Escape') { 563 + hideResults(); 564 + } 565 + }); 566 + 567 + handleInput.addEventListener('focus', () => { 568 + if (autocompleteResults.length > 0) { 569 + resultsDiv.classList.add('show'); 570 + } 571 + }); 572 + 573 + document.addEventListener('click', (e) => { 574 + if (!e.target.closest('.input-wrapper')) { 575 + hideResults(); 576 + } 577 + }); 378 578 379 579 // Atmosphere rendering 380 580 async function fetchAtmosphere() {