prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

header.js (6666B)


      1 import { isEditableTarget } from "./dom.js";
      2 
      3 const headerHandlers = {
      4   keydown: null,
      5   afterSwap: null,
      6   popstate: null,
      7   linkClick: null,
      8 };
      9 
     10 // Hosts that serve this same app, so a link to any of them stays in-page.
     11 const APP_HOSTS = new Set(["prime8.dev", "www.prime8.dev"]);
     12 // Pages the app renders into #content; anything else (raw files, the API,
     13 // static assets) is a real load.
     14 const APP_PATH = /^\/(?:(?:projects|experience)(?:\/[^/]+)?)?\/?$/;
     15 
     16 // opensOut reports whether an anchor should open in a new tab.
     17 function opensOut(a) {
     18   if (a.hasAttribute("download") || a.hasAttribute("hx-get")) return false;
     19   let url;
     20   try {
     21     url = new URL(a.href, window.location.href);
     22   } catch {
     23     return false;
     24   }
     25   if (!/^https?:$/.test(url.protocol)) return false;
     26   const here = new URL(window.location.href);
     27   if (url.origin === here.origin && url.pathname === here.pathname && url.hash) return false;
     28   return true;
     29 }
     30 
     31 // appPath returns the in-app path an anchor points at, or "" when clicking
     32 // it should be left to the browser.
     33 function appPath(a) {
     34   if (a.hasAttribute("download") || a.hasAttribute("hx-get")) return "";
     35   // links written into content (project READMEs and details) link out as written
     36   if (a.target === "_blank" || a.closest("#project-detail, .readme-content")) return "";
     37   let url;
     38   try {
     39     url = new URL(a.href, window.location.href);
     40   } catch {
     41     return "";
     42   }
     43   if (!/^https?:$/.test(url.protocol)) return "";
     44   const sameApp =
     45     url.origin === window.location.origin ||
     46     (APP_HOSTS.has(url.hostname) && APP_HOSTS.has(window.location.hostname));
     47   if (!sameApp || !APP_PATH.test(url.pathname)) return "";
     48   return url.pathname.replace(/\/+$/, "") || "/";
     49 }
     50 
     51 export function initHeader() {
     52   const headerEl = document.getElementById("tabs");
     53   if (!headerEl) return;
     54 
     55   const order = (headerEl.getAttribute("data-tabs") || "about").split(",").filter(Boolean);
     56   const fallback = order[0] || "about";
     57 
     58   function goTo(tab) {
     59     const link = document.querySelector(`.tab[data-tab="${tab}"]`);
     60     if (!link) return;
     61 
     62     const path = link.getAttribute("hx-get") || link.getAttribute("href");
     63     if (window.htmx && path) {
     64       setActiveTabUI(tab);
     65       if (window.location.pathname !== path) {
     66         history.pushState({ tab }, "", path);
     67       }
     68 
     69       window.htmx.ajax("GET", path, {
     70         target: "#content",
     71         swap: "innerHTML",
     72       });
     73       return;
     74     }
     75 
     76     link.click();
     77   }
     78 
     79   // Every app link (relative, or absolute to prime8.dev) outside project
     80   // content swaps #content instead of reloading the page. Modified
     81   // clicks (new tab, new window) are left alone.
     82   function navigate(path) {
     83     const tab = path === "/" ? fallback : path.slice(1).split("/")[0];
     84     setActiveTabUI(tab);
     85     if (window.location.pathname !== path) history.pushState({ tab }, "", path);
     86     window.htmx.ajax("GET", path, { target: "#content", swap: "innerHTML" });
     87   }
     88 
     89   if (headerHandlers.linkClick) {
     90     document.removeEventListener("click", headerHandlers.linkClick);
     91   }
     92   headerHandlers.linkClick = (e) => {
     93     if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
     94     const a = e.target.closest?.("a[href]");
     95     if (!a || !window.htmx) return;
     96     const path = appPath(a);
     97     if (path) {
     98       e.preventDefault();
     99       navigate(path);
    100       return;
    101     }
    102     // Everything else (other sites, raw files, links in project READMEs)
    103     // opens in a new tab so the app tab stays put. In-page #anchors and
    104     // non-http links (mailto:) are left to the browser.
    105     if (opensOut(a)) {
    106       e.preventDefault();
    107       window.open(a.href, "_blank", "noopener,noreferrer");
    108     }
    109   };
    110   document.addEventListener("click", headerHandlers.linkClick);
    111 
    112   if (headerHandlers.keydown) {
    113     document.removeEventListener("keydown", headerHandlers.keydown);
    114   }
    115 
    116   headerHandlers.keydown = (e) => {
    117     if (isEditableTarget(e.target)) return;
    118     if (typeof window.isLightboxOpen === "function" && window.isLightboxOpen()) return;
    119 
    120     const current = currentFromPath(order, fallback);
    121     const index = order.indexOf(current);
    122 
    123     if (e.key >= "1" && e.key <= String(order.length)) {
    124       const i = Number(e.key) - 1;
    125       if (order[i]) {
    126         e.preventDefault();
    127         if (order[i] === current) return;
    128         goTo(order[i]);
    129       }
    130       return;
    131     }
    132 
    133     switch (e.key) {
    134       case "Tab": {
    135         e.preventDefault();
    136         const next = order[(index + (e.shiftKey ? -1 : 1) + order.length) % order.length];
    137         goTo(next);
    138         break;
    139       }
    140       case "ArrowLeft":
    141         if (index > 0) {
    142           e.preventDefault();
    143           goTo(order[index - 1]);
    144         }
    145         break;
    146       case "ArrowRight":
    147         if (index < order.length - 1) {
    148           e.preventDefault();
    149           goTo(order[index + 1]);
    150         }
    151         break;
    152       default:
    153         break;
    154     }
    155   };
    156 
    157   document.addEventListener("keydown", headerHandlers.keydown);
    158 
    159   if (headerHandlers.afterSwap) {
    160     document.removeEventListener("htmx:afterSwap", headerHandlers.afterSwap);
    161   }
    162 
    163   headerHandlers.afterSwap = (e) => {
    164     if (!e.target) return;
    165 
    166     switch (e.target.id) {
    167       case "content":
    168         setActiveTabUI(currentFromPath(order, fallback));
    169         break;
    170       case "project-detail":
    171         updateAppTitle();
    172         break;
    173       default:
    174         break;
    175     }
    176   };
    177 
    178   document.addEventListener("htmx:afterSwap", headerHandlers.afterSwap);
    179 
    180   if (headerHandlers.popstate) {
    181     window.removeEventListener("popstate", headerHandlers.popstate);
    182   }
    183 
    184   headerHandlers.popstate = () => {
    185     setActiveTabUI(currentFromPath(order, fallback));
    186   };
    187 
    188   window.addEventListener("popstate", headerHandlers.popstate);
    189 
    190   setActiveTabUI(currentFromPath(order, fallback));
    191 }
    192 
    193 // Same as utils.WindowTitle: "prime8:~/projects/janitor", always in full.
    194 export function windowTitle(path) {
    195   return `prime8:${path}`;
    196 }
    197 
    198 export function updateAppTitle() {
    199   const path = window.location.pathname.replace(/\/+$/, "");
    200   document.title = windowTitle(`~${path}`);
    201 }
    202 
    203 function setActiveTabUI(tab) {
    204   document.querySelectorAll(".tab").forEach((el) => {
    205     const isActive = el.getAttribute("data-tab") === tab;
    206     el.classList.toggle("active", isActive);
    207     if (isActive) {
    208       el.setAttribute("aria-current", "page");
    209     } else {
    210       el.removeAttribute("aria-current");
    211     }
    212   });
    213 
    214   updateAppTitle();
    215 }
    216 
    217 function currentFromPath(order, fallback) {
    218   const path = window.location.pathname;
    219   if (path === "/" || path === "") return fallback;
    220   const tab = path.replace(/^\//, "").split("/")[0];
    221   return order.includes(tab) ? tab : fallback;
    222 }