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 <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>