repos
/ orchard main

orchard

mirror

Every 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

4.3 KB · 120 lines · JavaScript Raw History
  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});