diff options
Diffstat (limited to 'assets/js/site.js')
| -rw-r--r-- | assets/js/site.js | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/assets/js/site.js b/assets/js/site.js new file mode 100644 index 0000000..39891b7 --- /dev/null +++ b/assets/js/site.js @@ -0,0 +1,415 @@ +(function () { + function byId(id) { + return document.getElementById(id); + } + + const menuBtn = byId("menu-toggle"); + const nav = byId("site-nav"); + + const langToggle = byId("lang-toggle"); + const langMenu = byId("lang-options"); + const currentLang = byId("current-lang"); + + let openDropdown = null; + + function closeMenu() { + if (!menuBtn || !nav) return; + menuBtn.classList.remove("active"); + menuBtn.setAttribute("aria-expanded", "false"); + nav.hidden = true; + if (openDropdown === "menu") openDropdown = null; + } + + function closeLang() { + if (!langToggle || !langMenu) return; + langToggle.setAttribute("aria-expanded", "false"); + langMenu.hidden = true; + if (openDropdown === "lang") openDropdown = null; + } + + function toggleMenu(e) { + if (!menuBtn || !nav) return; + e.stopPropagation(); + + if (openDropdown === "lang") closeLang(); + + const open = menuBtn.getAttribute("aria-expanded") === "true"; + if (open) { + closeMenu(); + return; + } + + menuBtn.classList.add("active"); + menuBtn.setAttribute("aria-expanded", "true"); + nav.hidden = false; + openDropdown = "menu"; + } + + function toggleLang(e) { + if (!langToggle || !langMenu) return; + e.stopPropagation(); + + if (openDropdown === "menu") closeMenu(); + + const open = langToggle.getAttribute("aria-expanded") === "true"; + if (open) { + closeLang(); + return; + } + + langToggle.setAttribute("aria-expanded", "true"); + langMenu.hidden = false; + openDropdown = "lang"; + } + + if (nav) nav.hidden = true; + if (langMenu) langMenu.hidden = true; + + if (menuBtn && nav) { + menuBtn.addEventListener("click", toggleMenu); + nav.addEventListener("click", (e) => e.stopPropagation()); + } + + if (langToggle && langMenu) { + langToggle.addEventListener("click", toggleLang); + langMenu.addEventListener("click", (e) => e.stopPropagation()); + } + + if ((menuBtn && nav) || (langToggle && langMenu)) { + document.addEventListener("click", function (e) { + if ( + menuBtn && nav && + (menuBtn.contains(e.target) || nav.contains(e.target)) + ) { + return; + } + if ( + langToggle && langMenu && + (langToggle.contains(e.target) || langMenu.contains(e.target)) + ) { + return; + } + closeMenu(); + closeLang(); + }); + + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { + closeMenu(); + closeLang(); + } + }); + } + + if (currentLang) { + const docLang = (document.documentElement.lang || "en").toLowerCase(); + currentLang.textContent = docLang === "zh" ? "ZH" : docLang === "ja" ? "JA" : "EN"; + } + + if (langMenu) { + langMenu.querySelectorAll("a[data-lang]").forEach((a) => { + a.addEventListener("click", function () { + localStorage.setItem("preferredLang", this.getAttribute("data-lang") || "en"); + }); + }); + } + + // Language menu hrefs: convert current page to *_zh.html / *_jp.html. + // Needs to work for both: + // - directory URLs: /foo/ (serve /foo/index.html) + // - file URLs: /foo/page.html + function updateLangMenuHrefs() { + const links = document.querySelectorAll("a[data-lang-href][data-lang]"); + if (!links.length) return; + + let path = window.location.pathname || "/"; + if (!path.startsWith("/")) path = "/" + path; + + const isDir = path.endsWith("/"); + const file = isDir ? "index.html" : (path.split("/").pop() || "index.html"); + const base = isDir ? path : path.slice(0, -file.length); + + const baseName = file.replace(/_(zh|jp)\.html$/i, ".html"); + const map = { + en: baseName, + zh: baseName.replace(/(_(zh|jp))?\.html$/i, "_zh.html"), + ja: baseName.replace(/(_(zh|jp))?\.html$/i, "_jp.html"), + }; + + links.forEach((a) => { + const lang = (a.getAttribute("data-lang") || "en").toLowerCase(); + const targetFile = map[lang] || map.en; + a.href = base + targetFile; + }); + } + + function updateAriaCurrent() { + const path = window.location.pathname || "/"; + const normalized = path.startsWith("/") ? path : "/" + path; + const isDir = normalized.endsWith("/"); + const file = isDir ? "index.html" : (normalized.split("/").pop() || "index.html"); + const base = isDir ? normalized : normalized.slice(0, -file.length); + const indexPath = base + "index.html"; + const dirPath = base || "/"; + const candidates = new Set([normalized, indexPath, dirPath, dirPath.replace(/\/$/, "")]); + + document.querySelectorAll("#site-nav a[href]").forEach((a) => { + const href = a.getAttribute("href") || ""; + const hrefUrl = new URL(href, window.location.origin); + if (hrefUrl.origin !== window.location.origin) { + a.removeAttribute("aria-current"); + return; + } + const hrefPath = hrefUrl.pathname; + if (candidates.has(hrefPath)) { + a.setAttribute("aria-current", "page"); + } else { + a.removeAttribute("aria-current"); + } + }); + + const docLang = (document.documentElement.lang || "en").toLowerCase(); + document.querySelectorAll("#lang-options a[data-lang]").forEach((a) => { + const lang = (a.getAttribute("data-lang") || "en").toLowerCase(); + if (lang === docLang) { + a.setAttribute("aria-current", "page"); + } else { + a.removeAttribute("aria-current"); + } + }); + } + + // Run once now... + updateLangMenuHrefs(); + updateAriaCurrent(); + // ...and again after layout/injection settles. + setTimeout(updateLangMenuHrefs, 0); + setTimeout(updateAriaCurrent, 0); + + // Home page blog list (optional) + const blogList = byId("blog-list"); + if (blogList) { + const blogHost = "https://blog.sillylaird.ca"; + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 3500); + + fetch(blogHost + "/api/posts.php", { signal: ctrl.signal }) + .then((r) => r.json()) + .then((posts) => { + blogList.textContent = ""; + + const top = document.createElement("div"); + + const strong = document.createElement("strong"); + strong.textContent = "Posts"; + top.appendChild(strong); + + top.appendChild(document.createTextNode(" (")); + const allLink = document.createElement("a"); + allLink.href = blogHost; + allLink.textContent = "all"; + top.appendChild(allLink); + top.appendChild(document.createTextNode(")")); + + blogList.appendChild(top); + + const list = document.createElement("ol"); + (posts || []).forEach((post) => { + const li = document.createElement("li"); + + const a = document.createElement("a"); + a.href = blogHost + "/" + String(post.path || "").replace(/^\/+/, ""); + a.textContent = String(post.title || "Untitled"); + + const date = document.createElement("div"); + date.className = "muted"; + + const y = String(post.year || ""); + const m = String(post.month || "").padStart(2, "0"); + const d = String(post.day || "").padStart(2, "0"); + const iso = y && m && d ? `${y}-${m}-${d}` : ""; + + const time = document.createElement("time"); + if (iso) time.setAttribute("datetime", iso); + time.textContent = iso || ""; + + li.appendChild(a); + li.appendChild(document.createElement("br")); + date.appendChild(time); + li.appendChild(date); + + list.appendChild(li); + }); + + blogList.appendChild(list); + + if (!list.children.length) { + const empty = document.createElement("div"); + empty.className = "muted"; + empty.textContent = "No posts yet."; + blogList.appendChild(empty); + } + }) + .catch((err) => { + blogList.textContent = err && err.name === "AbortError" + ? "Blog list timed out." + : "Cannot load posts right now."; + }) + .finally(() => clearTimeout(timeout)); + } + + const changelogList = byId("changelog-list"); + if (changelogList) { + const changelogPage = "/changelog/"; + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 3500); + + fetch(changelogPage, { signal: ctrl.signal }) + .then((r) => r.text()) + .then((html) => { + changelogList.textContent = ""; + const doc = new DOMParser().parseFromString(html, "text/html"); + const entries = Array.from(doc.querySelectorAll(".changelog-entry")); + + const top = document.createElement("div"); + + const strong = document.createElement("strong"); + strong.textContent = "Entries"; + top.appendChild(strong); + + top.appendChild(document.createTextNode(" (")); + const allLink = document.createElement("a"); + allLink.href = "/changelog/"; + allLink.textContent = "all"; + top.appendChild(allLink); + top.appendChild(document.createTextNode(")")); + + changelogList.appendChild(top); + + const list = document.createElement("ol"); + entries.forEach((entry) => { + const li = document.createElement("li"); + + const titleText = (entry.querySelector("strong, b")?.textContent || "").trim(); + const timeEl = entry.querySelector("time"); + const dateText = (timeEl?.textContent || "").trim(); + const dateIso = (timeEl?.getAttribute("datetime") || "").trim(); + const bodyEl = entry.querySelector(".text"); + const bodyText = (bodyEl?.textContent || "").trim(); + + const title = document.createElement("strong"); + if (titleText) { + title.textContent = titleText; + li.appendChild(title); + + if (dateText) { + const date = document.createElement("div"); + date.className = "muted"; + const time = document.createElement("time"); + if (dateIso) time.setAttribute("datetime", dateIso); + time.textContent = dateText; + date.appendChild(time); + li.appendChild(date); + } + } else if (dateText) { + title.textContent = dateText; + li.appendChild(title); + } + + if (bodyText) { + const body = document.createElement("div"); + body.className = "changelog-body"; + body.textContent = bodyText; + li.appendChild(body); + } + + list.appendChild(li); + }); + + changelogList.appendChild(list); + + if (!list.children.length) { + const empty = document.createElement("div"); + empty.className = "muted"; + empty.textContent = "No updates yet."; + changelogList.appendChild(empty); + } + }) + .catch((err) => { + changelogList.textContent = err && err.name === "AbortError" + ? "Changelog list timed out." + : "Cannot load changelog right now."; + }) + .finally(() => clearTimeout(timeout)); + } + + const resetBtn = byId("guestbook-reset"); + if (resetBtn) { + resetBtn.addEventListener("click", () => { + const frame = byId("guestbook-form-iframe"); + if (!frame) return; + setIframeLoading(frame); + const src = frame.getAttribute("src") || frame.src; + if (src) { + // Reset by reloading the iframe URL (works cross-origin too). + frame.setAttribute("src", src); + return; + } + if (frame.contentWindow) frame.contentWindow.location.reload(); + }); + } + + function setIframeLoading(frame) { + if (!frame) return; + const clip = frame.closest(".iframe-clip"); + if (!clip) return; + clip.classList.add("is-loading"); + const onLoad = () => { + clip.classList.remove("is-loading"); + }; + frame.addEventListener("load", onLoad, { once: true }); + } + + function resetIframe(frame) { + if (!frame) return; + const src = frame.getAttribute("src") || frame.src; + if (src) { + frame.setAttribute("src", src); + return; + } + if (frame.contentWindow) frame.contentWindow.location.reload(); + } + + document.querySelectorAll("button[data-reset-iframe]").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.getAttribute("data-reset-iframe"); + if (!key) return; + const scope = btn.closest(".iframe-clip") || btn.parentElement || document; + const frame = scope.querySelector(`iframe[data-iframe=\"${key}\"]`) || document.querySelector(`iframe[data-iframe=\"${key}\"]`); + setIframeLoading(frame); + resetIframe(frame); + }); + }); + + // Footer year: local time only. + (function () { + const el = byId("copyright-year"); + if (!el) return; + + el.textContent = String(new Date().getFullYear()); + })(); + + // Footer last-updated: based on document.lastModified. + (function () { + const el = byId("last-updated"); + if (!el) return; + + const raw = document.lastModified; + const date = raw ? new Date(raw) : null; + if (!date || Number.isNaN(date.getTime())) return; + + const iso = date.toISOString().slice(0, 10); + el.setAttribute("datetime", iso); + el.textContent = iso; + })(); +})(); |
