orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// Live search for any "id_search" field, against /search/live/?q=<query>.
2// Arrow keys navigate the results and Enter follows the selected link.
3
4document.addEventListener("DOMContentLoaded", function () {
5 const searchEl = document.getElementById("id_search");
6 if (!searchEl) return;
7
8 let activeIndex = -1;
9
10 const getItems = () => {
11 const container = searchEl.parentElement.querySelector("#id_search_results ul");
12 return container ? Array.from(container.querySelectorAll("a")) : [];
13 };
14
15 const setActive = (index) => {
16 const items = getItems();
17 items.forEach((item) => item.classList.remove("active"));
18 activeIndex = index;
19 if (index >= 0 && index < items.length) {
20 items[index].classList.add("active");
21 items[index].scrollIntoView({ block: "nearest" });
22 }
23 };
24
25 const clearResults = () => {
26 const resultsEl = searchEl.parentElement.querySelector("#id_search_results");
27 if (resultsEl) {
28 searchEl.parentElement.removeChild(resultsEl);
29 }
30 activeIndex = -1;
31 };
32
33 searchEl.addEventListener("keydown", function (e) {
34 const items = getItems();
35 if (!items.length) return;
36
37 if (e.key === "ArrowDown") {
38 e.preventDefault();
39 setActive(activeIndex < items.length - 1 ? activeIndex + 1 : 0);
40 } else if (e.key === "ArrowUp") {
41 e.preventDefault();
42 setActive(activeIndex > 0 ? activeIndex - 1 : items.length - 1);
43 } else if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
44 e.preventDefault();
45 window.location.href = items[activeIndex].href;
46 }
47 });
48
49 searchEl.addEventListener("keyup", function (e) {
50 if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;
51
52 const query = searchEl.value;
53 if (query.length === 0) {
54 clearResults();
55 return;
56 }
57
58 const url = "/search/live/?q=" + encodeURIComponent(query);
59 fetch(url)
60 .then((response) => response.json())
61 .then((data) => {
62 clearResults();
63 if (!data.length) return;
64
65 const resultsEl = document.createElement("div");
66 resultsEl.id = "id_search_results";
67 searchEl.parentElement.appendChild(resultsEl);
68
69 const resultsDiv = document.createElement("ul");
70 resultsDiv.classList.add("list-group", "rounded-bottom", "position-absolute", "mt-2");
71 resultsDiv.style.zIndex = "1100";
72 resultsDiv.style.width = searchEl.offsetWidth + "px";
73 resultsDiv.style.backgroundColor = "#13120e";
74 resultsDiv.style.border = "1px solid rgba(107,158,120,0.15)";
75 resultsEl.appendChild(resultsDiv);
76
77 // Rows are built from text nodes, so a query with regex metacharacters
78 // ("c++") cannot throw and post text is never parsed as HTML.
79 const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80 const appendHighlighted = (parent, text, strongClass) => {
81 const re = new RegExp(escapeRegExp(query), "gi");
82 let last = 0;
83 let m;
84 while ((m = re.exec(text)) !== null) {
85 parent.appendChild(document.createTextNode(text.slice(last, m.index)));
86 const strong = document.createElement("strong");
87 if (strongClass) strong.className = strongClass;
88 strong.textContent = m[0];
89 parent.appendChild(strong);
90 last = m.index + m[0].length;
91 }
92 parent.appendChild(document.createTextNode(text.slice(last)));
93 };
94
95 data.forEach((result) => {
96 const a = document.createElement("a");
97 a.classList.add("list-group-item", "list-group-item-action");
98 const titleSpan = document.createElement("span");
99 titleSpan.className = "fw-bold";
100 appendHighlighted(titleSpan, result.title, "");
101 const descSpan = document.createElement("span");
102 descSpan.className = "text-muted";
103 appendHighlighted(descSpan, result.description, "fw-bolder");
104 a.appendChild(titleSpan);
105 a.appendChild(document.createElement("br"));
106 a.appendChild(descSpan);
107 a.href = result.url;
108 resultsDiv.appendChild(a);
109 });
110 });
111 });
112
113 searchEl.addEventListener("blur", function (e) {
114 const searchResults = searchEl.parentElement.querySelector("#id_search_results");
115 if (searchResults && !searchResults.contains(e.relatedTarget)) {
116 clearResults();
117 }
118 });
119});