- Inline JS in base.html: debounced fetch from /autocompleter on keyup - Keyboard nav: arrows to navigate, Enter to select, Esc to close - Highlight matching prefix in suggestions - Click to select and submit - Dropdown positioned absolutely below search input - Dark mode compatible via existing CSS variables
142 lines
4.8 KiB
HTML
142 lines
4.8 KiB
HTML
{{define "base"}}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="referrer" content="no-referrer">
|
|
<meta name="robots" content="noarchive">
|
|
<meta name="description" content="kafka — a privacy-respecting, open metasearch engine">
|
|
<title>{{template "title" .}}kafka</title>
|
|
<link rel="stylesheet" href="/static/css/kafka.css">
|
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
|
|
<link title="kafka" type="application/opensearchdescription+xml" rel="search" href="/opensearch.xml">
|
|
</head>
|
|
<body class="{{if .Query}}search_on_results{{end}}">
|
|
<main>
|
|
{{template "content" .}}
|
|
</main>
|
|
<footer>
|
|
<p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
|
|
</footer>
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var input = document.getElementById('q');
|
|
var dropdown = document.getElementById('autocomplete-dropdown');
|
|
var form = document.getElementById('search-form');
|
|
var debounceTimer = null;
|
|
var suggestions = [];
|
|
var activeIndex = -1;
|
|
var fetchController = null;
|
|
|
|
// Escape regex special chars for highlight matching
|
|
function escapeRegex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
// Highlight matching prefix
|
|
function highlight(text, query) {
|
|
if (!query) return text;
|
|
var re = new RegExp('^(' + escapeRegex(query) + ')', 'i');
|
|
return text.replace(re, '<mark>$1</mark>');
|
|
}
|
|
|
|
function renderDropdown() {
|
|
if (suggestions.length === 0) {
|
|
dropdown.classList.remove('open');
|
|
return;
|
|
}
|
|
var html = '';
|
|
for (var i = 0; i < suggestions.length; i++) {
|
|
var escaped = highlight(suggestions[i], input.value);
|
|
html += '<div class="autocomplete-suggestion" data-index="' + i + '">' + escaped + '</div>';
|
|
}
|
|
html += '<div class="autocomplete-footer">Press <kbd>↑</kbd><kbd>↓</kbd> to navigate, Enter to select, Esc to close</div>';
|
|
dropdown.innerHTML = html;
|
|
dropdown.classList.add('open');
|
|
activeIndex = -1;
|
|
}
|
|
|
|
function closeDropdown() {
|
|
dropdown.classList.remove('open');
|
|
suggestions = [];
|
|
activeIndex = -1;
|
|
}
|
|
|
|
function selectSuggestion(index) {
|
|
if (index < 0 || index >= suggestions.length) return;
|
|
input.value = suggestions[index];
|
|
closeDropdown();
|
|
form.submit();
|
|
}
|
|
|
|
function updateActive(newIndex) {
|
|
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
|
items.forEach(function (el) { el.classList.remove('active'); });
|
|
if (newIndex >= 0 && newIndex < items.length) {
|
|
items[newIndex].classList.add('active');
|
|
items[newIndex].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
activeIndex = newIndex;
|
|
}
|
|
|
|
function fetchSuggestions(query) {
|
|
if (fetchController) fetchController.abort();
|
|
fetchController = new AbortController();
|
|
fetch('/autocompleter?q=' + encodeURIComponent(query), { signal: fetchController.signal })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
suggestions = data || [];
|
|
renderDropdown();
|
|
})
|
|
.catch(function (e) {
|
|
if (e.name !== 'AbortError') suggestions = [];
|
|
dropdown.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
input.addEventListener('input', function () {
|
|
clearTimeout(debounceTimer);
|
|
var q = input.value.trim();
|
|
if (q.length < 2) { closeDropdown(); return; }
|
|
debounceTimer = setTimeout(function () { fetchSuggestions(q); }, 250);
|
|
});
|
|
|
|
input.addEventListener('keydown', function (e) {
|
|
if (!dropdown.classList.contains('open')) return;
|
|
var items = dropdown.querySelectorAll('.autocomplete-suggestion');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
updateActive(Math.min(activeIndex + 1, items.length - 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
updateActive(Math.max(activeIndex - 1, -1));
|
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
|
e.preventDefault();
|
|
selectSuggestion(activeIndex);
|
|
} else if (e.key === 'Escape') {
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
input.addEventListener('blur', function () {
|
|
// Delay to allow click events on suggestions
|
|
setTimeout(closeDropdown, 150);
|
|
});
|
|
|
|
dropdown.addEventListener('mousedown', function (e) {
|
|
var item = e.target.closest('.autocomplete-suggestion');
|
|
if (item) {
|
|
e.preventDefault(); // prevent blur from firing before select
|
|
var idx = parseInt(item.getAttribute('data-index'), 10);
|
|
selectSuggestion(idx);
|
|
}
|
|
});
|
|
}());
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|