semantic bufo search
find-bufo.com
bufo
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>