semantic bufo search find-bufo.com
bufo
at fix-query-length-error-handling 797 lines 25 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>find bufo</title> 7 <link rel="icon" type="image/png" href="/static/favicon.png"> 8 <style> 9 * { 10 margin: 0; 11 padding: 0; 12 box-sizing: border-box; 13 } 14 15 body { 16 font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; 17 background: #8ba888; 18 min-height: 100vh; 19 display: flex; 20 justify-content: center; 21 align-items: center; 22 padding: 20px; 23 position: relative; 24 overflow-x: hidden; 25 } 26 27 .container { 28 max-width: 800px; 29 width: 100%; 30 padding: 0 10px; 31 } 32 33 .header { 34 text-align: center; 35 margin-bottom: 40px; 36 } 37 38 h1 { 39 color: white; 40 font-size: 3em; 41 font-weight: 700; 42 margin-bottom: 10px; 43 text-shadow: 2px 2px 4px rgba(0,0,0,0.2); 44 } 45 46 h1 a { 47 color: inherit; 48 text-decoration: none; 49 cursor: pointer; 50 transition: opacity 0.2s; 51 } 52 53 h1 a:hover { 54 opacity: 0.8; 55 } 56 57 .subtitle { 58 color: rgba(255, 255, 255, 0.9); 59 font-size: 1.1em; 60 margin-bottom: 15px; 61 } 62 63 .subtitle a { 64 color: inherit; 65 text-decoration: underline; 66 text-decoration-color: rgba(255, 255, 255, 0.4); 67 transition: text-decoration-color 0.2s; 68 } 69 70 .subtitle a:hover { 71 text-decoration-color: rgba(255, 255, 255, 0.9); 72 } 73 74 .search-box { 75 background: white; 76 border-radius: 12px; 77 padding: 20px; 78 box-shadow: 0 10px 40px rgba(0,0,0,0.2); 79 margin-bottom: 20px; 80 } 81 82 .search-options { 83 margin-top: 15px; 84 padding-top: 15px; 85 border-top: 1px solid #e0e0e0; 86 } 87 88 .search-options.collapsed { 89 display: none; 90 } 91 92 .options-toggle { 93 background: rgba(102, 126, 234, 0.1); 94 border: 1.5px solid rgba(102, 126, 234, 0.3); 95 border-radius: 6px; 96 color: #667eea; 97 font-size: 0.85em; 98 font-family: inherit; 99 font-weight: 600; 100 cursor: pointer; 101 padding: 8px 14px; 102 margin-top: 10px; 103 transition: all 0.2s; 104 display: inline-flex; 105 align-items: center; 106 gap: 6px; 107 } 108 109 .options-toggle:hover { 110 background: rgba(102, 126, 234, 0.15); 111 border-color: rgba(102, 126, 234, 0.5); 112 transform: translateY(-1px); 113 } 114 115 .options-toggle svg { 116 width: 14px; 117 height: 14px; 118 } 119 120 .option-group { 121 margin-bottom: 15px; 122 } 123 124 .option-label { 125 display: flex; 126 justify-content: space-between; 127 align-items: center; 128 margin-bottom: 8px; 129 font-size: 0.9em; 130 color: #555; 131 } 132 133 .option-name { 134 font-weight: 600; 135 } 136 137 .option-name a { 138 text-decoration: none; 139 color: inherit; 140 border-bottom: 1px dotted #999; 141 transition: border-color 0.2s, color 0.2s; 142 } 143 144 .option-name a:hover { 145 color: #667eea; 146 border-bottom-color: #667eea; 147 } 148 149 .option-value { 150 color: #667eea; 151 font-weight: 700; 152 font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; 153 } 154 155 .option-description { 156 font-size: 0.8em; 157 color: #888; 158 margin-bottom: 8px; 159 line-height: 1.4; 160 } 161 162 input[type="range"] { 163 width: 100%; 164 height: 6px; 165 border-radius: 3px; 166 background: #e0e0e0; 167 outline: none; 168 -webkit-appearance: none; 169 } 170 171 input[type="range"]::-webkit-slider-thumb { 172 -webkit-appearance: none; 173 appearance: none; 174 width: 18px; 175 height: 18px; 176 border-radius: 50%; 177 background: #667eea; 178 cursor: pointer; 179 } 180 181 input[type="range"]::-moz-range-thumb { 182 width: 18px; 183 height: 18px; 184 border-radius: 50%; 185 background: #667eea; 186 cursor: pointer; 187 border: none; 188 } 189 190 .alpha-markers { 191 display: flex; 192 justify-content: space-between; 193 font-size: 0.75em; 194 color: #aaa; 195 margin-top: 4px; 196 } 197 198 .checkbox-wrapper { 199 display: flex; 200 align-items: center; 201 gap: 10px; 202 cursor: pointer; 203 user-select: none; 204 } 205 206 input[type="checkbox"] { 207 width: 18px; 208 height: 18px; 209 cursor: pointer; 210 accent-color: #667eea; 211 } 212 213 .sample-queries-container { 214 text-align: center; 215 margin-bottom: 30px; 216 } 217 218 .sample-queries-container.hidden { 219 display: none; 220 } 221 222 .sample-queries-label { 223 color: rgba(255, 255, 255, 0.7); 224 font-size: 0.85em; 225 margin-bottom: 10px; 226 font-weight: 500; 227 } 228 229 .sample-queries { 230 display: flex; 231 gap: 8px; 232 justify-content: center; 233 flex-wrap: wrap; 234 } 235 236 .sample-query-btn { 237 padding: 8px 16px; 238 background: rgba(255, 255, 255, 0.15); 239 backdrop-filter: blur(10px); 240 border: 1.5px solid rgba(255, 255, 255, 0.3); 241 border-radius: 6px; 242 font-size: 0.85em; 243 font-family: inherit; 244 font-weight: 600; 245 cursor: pointer; 246 transition: all 0.2s; 247 color: white; 248 text-shadow: 0 1px 2px rgba(0,0,0,0.2); 249 } 250 251 .sample-query-btn.happy { 252 background: rgba(255, 220, 100, 0.25); 253 border-color: rgba(255, 220, 100, 0.4); 254 } 255 256 .sample-query-btn.happy:hover { 257 background: rgba(255, 220, 100, 0.4); 258 border-color: rgba(255, 220, 100, 0.6); 259 transform: translateY(-2px); 260 } 261 262 .sample-query-btn.apocalyptic { 263 background: rgba(255, 80, 80, 0.25); 264 border-color: rgba(255, 80, 80, 0.4); 265 } 266 267 .sample-query-btn.apocalyptic:hover { 268 background: rgba(255, 80, 80, 0.4); 269 border-color: rgba(255, 80, 80, 0.6); 270 transform: translateY(-2px); 271 } 272 273 .sample-query-btn.giving { 274 background: rgba(100, 220, 150, 0.25); 275 border-color: rgba(100, 220, 150, 0.4); 276 } 277 278 .sample-query-btn.giving:hover { 279 background: rgba(100, 220, 150, 0.4); 280 border-color: rgba(100, 220, 150, 0.6); 281 transform: translateY(-2px); 282 } 283 284 @media (max-width: 600px) { 285 .sample-query-btn { 286 font-size: 0.8em; 287 padding: 6px 12px; 288 } 289 290 .sample-queries { 291 gap: 6px; 292 } 293 } 294 295 .search-input-wrapper { 296 display: flex; 297 gap: 10px; 298 width: 100%; 299 } 300 301 input[type="text"] { 302 flex: 1; 303 min-width: 0; 304 padding: 15px; 305 border: 2px solid #e0e0e0; 306 border-radius: 8px; 307 font-size: 16px; 308 font-family: inherit; 309 transition: border-color 0.3s; 310 } 311 312 input[type="text"]:focus { 313 outline: none; 314 border-color: #667eea; 315 } 316 317 button { 318 padding: 15px 30px; 319 background: #667eea; 320 color: white; 321 border: none; 322 border-radius: 8px; 323 font-size: 16px; 324 font-family: inherit; 325 font-weight: 600; 326 cursor: pointer; 327 transition: background 0.3s; 328 white-space: nowrap; 329 flex-shrink: 0; 330 } 331 332 button:hover { 333 background: #5568d3; 334 } 335 336 button:disabled { 337 background: #ccc; 338 cursor: not-allowed; 339 } 340 341 .results { 342 display: grid; 343 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); 344 gap: 15px; 345 } 346 347 @media (max-width: 600px) { 348 .results { 349 grid-template-columns: 1fr; 350 max-width: 400px; 351 margin: 0 auto; 352 } 353 354 h1 { 355 font-size: 2.5em; 356 } 357 358 .subtitle { 359 font-size: 1em; 360 } 361 362 .info { 363 font-size: 0.85em; 364 } 365 } 366 367 .bufo-card { 368 background: white; 369 border-radius: 12px; 370 padding: 15px; 371 box-shadow: 0 4px 6px rgba(0,0,0,0.1); 372 transition: transform 0.2s, box-shadow 0.2s; 373 cursor: pointer; 374 text-align: center; 375 } 376 377 .bufo-card:hover { 378 transform: translateY(-5px); 379 box-shadow: 0 8px 12px rgba(0,0,0,0.15); 380 } 381 382 .bufo-image { 383 width: 100%; 384 height: 150px; 385 object-fit: contain; 386 margin-bottom: 10px; 387 } 388 389 @media (max-width: 600px) { 390 .bufo-image { 391 height: 200px; 392 } 393 } 394 395 .bufo-name { 396 font-size: 0.9em; 397 color: #333; 398 font-weight: 500; 399 word-break: break-word; 400 } 401 402 .bufo-score { 403 font-size: 0.8em; 404 color: #888; 405 margin-top: 5px; 406 } 407 408 .loading { 409 text-align: center; 410 color: white; 411 font-size: 1.2em; 412 } 413 414 .error { 415 background: #ff6b6b; 416 color: white; 417 padding: 15px; 418 border-radius: 8px; 419 margin-bottom: 20px; 420 } 421 422 .no-results { 423 grid-column: 1 / -1; 424 display: flex; 425 flex-direction: column; 426 align-items: center; 427 justify-content: center; 428 text-align: center; 429 padding: 40px 20px; 430 margin: 0 auto; 431 } 432 433 .no-results-text { 434 font-size: 3em; 435 margin-bottom: 50px; 436 font-weight: 700; 437 color: white; 438 text-shadow: 2px 2px 4px rgba(0,0,0,0.2); 439 } 440 441 .no-results-bufo { 442 max-width: 600px; 443 width: 100%; 444 height: auto; 445 display: block; 446 margin: 0 auto; 447 } 448 449 @media (max-width: 600px) { 450 .no-results-text { 451 font-size: 2em; 452 } 453 454 .no-results-bufo { 455 max-width: 300px; 456 } 457 } 458 459 @keyframes peek-in-out { 460 0% { 461 transform: var(--peek-start); 462 } 463 20% { 464 transform: var(--peek-in); 465 } 466 80% { 467 transform: var(--peek-in); 468 } 469 100% { 470 transform: var(--peek-start); 471 } 472 } 473 474 .peeking-bufo { 475 position: fixed; 476 pointer-events: none; 477 z-index: 1000; 478 width: 200px; 479 height: auto; 480 animation: peek-in-out 6s ease-in-out infinite; 481 } 482 483 .peeking-bufo.hidden { 484 opacity: 0; 485 pointer-events: none; 486 animation: none; 487 } 488 489 .peeking-bufo-right { 490 right: -200px; 491 top: 50%; 492 --peek-start: translateY(-50%); 493 --peek-in: translateX(-200px) translateY(-50%); 494 } 495 496 .peeking-bufo-bottom { 497 bottom: -200px; 498 left: 50%; 499 --peek-start: translateX(-50%) rotate(90deg); 500 --peek-in: translateX(-50%) translateY(-200px) rotate(90deg); 501 } 502 503 .peeking-bufo-left { 504 left: -200px; 505 top: 50%; 506 --peek-start: translateY(-50%) scaleX(-1); 507 --peek-in: translateX(200px) translateY(-50%) scaleX(-1); 508 } 509 510 .peeking-bufo-top { 511 top: -200px; 512 left: 50%; 513 --peek-start: translateX(-50%) rotate(-90deg); 514 --peek-in: translateX(-50%) translateY(200px) rotate(-90deg); 515 } 516 517 @media (max-width: 1024px) { 518 .peeking-bufo { 519 width: 150px; 520 } 521 } 522 523 @media (max-width: 768px) { 524 .peeking-bufo { 525 width: 100px; 526 } 527 } 528 </style> 529</head> 530<body> 531 <div class="container"> 532 <div class="header"> 533 <h1><a href="/">find bufo</a></h1> 534 <p class="subtitle"><a href="https://tangled.org/@zzstoatzz.io/find-bufo/blob/main/src/search.rs#L1-L41" target="_blank">hybrid search</a> for <a href="https://bufo.zone" target="_blank">bufo.zone</a></p> 535 </div> 536 537 <div class="search-box"> 538 <div class="search-input-wrapper"> 539 <input 540 type="text" 541 id="searchInput" 542 placeholder="describe the bufo you seek..." 543 autocomplete="off" 544 > 545 <button id="searchButton">search</button> 546 </div> 547 548 <button class="options-toggle" id="optionsToggle"> 549 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 550 <line x1="4" y1="6" x2="20" y2="6"></line> 551 <line x1="4" y1="12" x2="20" y2="12"></line> 552 <line x1="4" y1="18" x2="20" y2="18"></line> 553 <circle cx="7" cy="6" r="2" fill="currentColor"></circle> 554 <circle cx="14" cy="12" r="2" fill="currentColor"></circle> 555 <circle cx="17" cy="18" r="2" fill="currentColor"></circle> 556 </svg> 557 <span id="optionsToggleText">search filters</span> 558 </button> 559 560 <div class="search-options collapsed" id="searchOptions"> 561 <div class="option-group"> 562 <div class="option-label"> 563 <span class="option-name">search mode (<a href="https://tangled.org/@zzstoatzz.io/find-bufo/blob/main/src/search.rs#L22" target="_blank">α</a>)</span> 564 <span class="option-value" id="alphaValue">0.70</span> 565 </div> 566 <div class="option-description"> 567 balance between semantic understanding and exact keyword matching 568 </div> 569 <input 570 type="range" 571 id="alphaSlider" 572 min="0" 573 max="1" 574 step="0.01" 575 value="0.7" 576 > 577 <div class="alpha-markers"> 578 <span>keyword</span> 579 <span>balanced</span> 580 <span>semantic</span> 581 </div> 582 </div> 583 584 <div class="option-group"> 585 <div class="option-label"> 586 <span class="option-name">family-friendly mode</span> 587 </div> 588 <div class="option-description"> 589 filter out inappropriate content 590 </div> 591 <label class="checkbox-wrapper"> 592 <input 593 type="checkbox" 594 id="familyFriendlyCheckbox" 595 checked 596 > 597 <span>enabled</span> 598 </label> 599 </div> 600 </div> 601 </div> 602 603 <div id="sampleQueriesContainer" class="sample-queries-container"> 604 <div class="sample-queries-label">try a sample query:</div> 605 <div class="sample-queries"> 606 <button class="sample-query-btn happy" data-query="happy">happy</button> 607 <button class="sample-query-btn apocalyptic" data-query="apocalyptic">apocalyptic</button> 608 <button class="sample-query-btn giving" data-query="in a giving mood">in a giving mood</button> 609 </div> 610 </div> 611 612 <div id="error" class="error" style="display: none;"></div> 613 <div id="loading" class="loading" style="display: none;">searching...</div> 614 <div id="results" class="results"></div> 615 </div> 616 617 <img src="https://all-the.bufo.zone/bufo-just-checking.gif" alt="bufo peeking" class="peeking-bufo" id="peekingBufo"> 618 619 <script src="/static/bufo-peek.js"></script> 620 <script> 621 const searchInput = document.getElementById('searchInput'); 622 const searchButton = document.getElementById('searchButton'); 623 const resultsDiv = document.getElementById('results'); 624 const loadingDiv = document.getElementById('loading'); 625 const errorDiv = document.getElementById('error'); 626 const sampleQueriesContainer = document.getElementById('sampleQueriesContainer'); 627 const optionsToggle = document.getElementById('optionsToggle'); 628 const searchOptions = document.getElementById('searchOptions'); 629 const alphaSlider = document.getElementById('alphaSlider'); 630 const alphaValue = document.getElementById('alphaValue'); 631 const familyFriendlyCheckbox = document.getElementById('familyFriendlyCheckbox'); 632 633 let hasSearched = false; 634 635 // toggle search options 636 const optionsToggleText = document.getElementById('optionsToggleText'); 637 optionsToggle.addEventListener('click', () => { 638 searchOptions.classList.toggle('collapsed'); 639 optionsToggleText.textContent = searchOptions.classList.contains('collapsed') 640 ? 'search filters' 641 : 'hide filters'; 642 }); 643 644 // update alpha value display 645 alphaSlider.addEventListener('input', (e) => { 646 alphaValue.textContent = parseFloat(e.target.value).toFixed(2); 647 }); 648 649 async function search(updateUrl = true) { 650 const query = searchInput.value.trim(); 651 if (!query) return; 652 653 const alpha = parseFloat(alphaSlider.value); 654 const familyFriendly = familyFriendlyCheckbox.checked; 655 656 // hide bufo after first search 657 if (!hasSearched) { 658 window.dispatchEvent(new Event('bufo-hide')); 659 hasSearched = true; 660 } 661 662 // update url with query parameters for sharing 663 if (updateUrl) { 664 const params = new URLSearchParams(); 665 params.set('q', query); 666 params.set('top_k', '20'); 667 params.set('alpha', alpha.toString()); 668 params.set('family_friendly', familyFriendly.toString()); 669 const newUrl = `${window.location.pathname}?${params.toString()}`; 670 window.history.pushState({ query, alpha, familyFriendly }, '', newUrl); 671 } 672 673 searchButton.disabled = true; 674 loadingDiv.style.display = 'block'; 675 resultsDiv.innerHTML = ''; 676 errorDiv.style.display = 'none'; 677 sampleQueriesContainer.classList.add('hidden'); 678 679 try { 680 const params = new URLSearchParams(); 681 params.set('query', query); 682 params.set('top_k', '20'); 683 params.set('alpha', alpha.toString()); 684 params.set('family_friendly', familyFriendly.toString()); 685 686 const response = await fetch(`/api/search?${params.toString()}`, { 687 method: 'GET', 688 headers: { 689 'Accept': 'application/json', 690 }, 691 }); 692 693 if (!response.ok) { 694 // try to extract error message from response body 695 let errorMessage = response.statusText; 696 try { 697 const errorText = await response.text(); 698 // actix-web returns plain text error messages, not JSON 699 if (errorText) { 700 errorMessage = errorText; 701 } 702 } catch (e) { 703 // if reading body fails, use the status text 704 } 705 throw new Error(errorMessage); 706 } 707 708 const data = await response.json(); 709 displayResults(data.results); 710 } catch (error) { 711 errorDiv.textContent = error.message; 712 errorDiv.style.display = 'block'; 713 } finally { 714 searchButton.disabled = false; 715 loadingDiv.style.display = 'none'; 716 } 717 } 718 719 function displayResults(results) { 720 if (results.length === 0) { 721 resultsDiv.innerHTML = ` 722 <div class="no-results"> 723 <div class="no-results-text">no bufos found</div> 724 <img src="https://all-the.bufo.zone/bufo-shrug.png" alt="bufo shrug" class="no-results-bufo"> 725 </div> 726 `; 727 return; 728 } 729 730 results.forEach(bufo => { 731 const card = document.createElement('div'); 732 card.className = 'bufo-card'; 733 card.onclick = () => window.open(bufo.url, '_blank'); 734 735 card.innerHTML = ` 736 <img src="${bufo.url}" alt="${bufo.name}" class="bufo-image" loading="lazy"> 737 <div class="bufo-name">${bufo.name}</div> 738 <div class="bufo-score">${(bufo.score * 100).toFixed(1)}%</div> 739 `; 740 741 resultsDiv.appendChild(card); 742 }); 743 } 744 745 searchButton.addEventListener('click', () => search(true)); 746 searchInput.addEventListener('keypress', (e) => { 747 if (e.key === 'Enter') search(true); 748 }); 749 750 // handle browser back/forward 751 window.addEventListener('popstate', (e) => { 752 if (e.state && e.state.query) { 753 searchInput.value = e.state.query; 754 if (e.state.alpha !== undefined) { 755 alphaSlider.value = e.state.alpha; 756 alphaValue.textContent = parseFloat(e.state.alpha).toFixed(2); 757 } 758 if (e.state.familyFriendly !== undefined) { 759 familyFriendlyCheckbox.checked = e.state.familyFriendly; 760 } 761 search(false); 762 } 763 }); 764 765 // auto-execute search if url has query params (for shared links) 766 window.addEventListener('DOMContentLoaded', () => { 767 const params = new URLSearchParams(window.location.search); 768 const query = params.get('q'); 769 const alpha = params.get('alpha'); 770 const familyFriendly = params.get('family_friendly'); 771 772 if (alpha) { 773 alphaSlider.value = alpha; 774 alphaValue.textContent = parseFloat(alpha).toFixed(2); 775 } 776 777 if (familyFriendly !== null) { 778 familyFriendlyCheckbox.checked = familyFriendly === 'true'; 779 } 780 781 if (query) { 782 searchInput.value = query; 783 search(false); // don't update URL since we're already loading from it 784 } 785 }); 786 787 // handle sample query button clicks 788 document.querySelectorAll('.sample-query-btn').forEach(btn => { 789 btn.addEventListener('click', () => { 790 const query = btn.getAttribute('data-query'); 791 searchInput.value = query; 792 search(true); 793 }); 794 }); 795 </script> 796</body> 797</html>