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