diff mrjunejune/src/jrpg/jrpg.js @ 262:0f45474c1b1a

Add cyberpunk JRPG blog browser Show the latest posts in the JRPG preview and render the full blog archive and sanitized post details in the Inspect modal. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Thu, 06 Aug 2026 04:08:45 -0700
parents b401627fc49e
children ee04e4e69fed
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Thu Aug 06 04:08:45 2026 -0700
@@ -435,14 +435,62 @@
   }
 }
 
+function sanitizeFetchedMain(main) {
+  for (const unsafe of main.querySelectorAll(
+    "embed, form, iframe, object, script, style, svg",
+  )) {
+    unsafe.remove();
+  }
+  for (const element of main.querySelectorAll("*")) {
+    for (const attribute of [...element.attributes]) {
+      if (attribute.name.toLowerCase().startsWith("on")) {
+        element.removeAttribute(attribute.name);
+      }
+    }
+  }
+  for (const anchor of main.querySelectorAll("a")) {
+    const target = new URL(
+      anchor.getAttribute("href") || "",
+      window.location.href,
+    );
+    anchor.href = target.href;
+    if (target.origin !== window.location.origin) {
+      anchor.target = "_blank";
+      anchor.rel = "noreferrer";
+    }
+  }
+  for (const image of main.querySelectorAll("img")) {
+    const source = image.getAttribute("src") || "";
+    if (source.startsWith("/public/") && source.endsWith(".png")) {
+      image.src = source.replace(/\.png$/i, ".webp");
+    }
+    image.loading = "lazy";
+  }
+  return main;
+}
+
 class MjjJrpgPreview extends HTMLElement {
   connectedCallback() {
     this._onClick = event => {
-      if (
-        event.target.closest("[data-zen-trigger]") &&
-        this.dataset.selection === "resume"
-      ) {
-        void this.loadResume();
+      const trigger = event.target.closest("[data-zen-trigger]");
+      if (trigger) {
+        if (this.dataset.selection === "resume") void this.loadResume();
+        if (this.dataset.selection === "blog") {
+          this.show("blog");
+          void this.loadBlogs();
+        }
+        return;
+      }
+      const blogEntry = event.target.closest("[data-blog-entry]");
+      if (blogEntry) {
+        void this.loadBlogDetail(blogEntry.dataset.blogUrl);
+        return;
+      }
+      const latestBlog = event.target.closest("[data-latest-blog]");
+      if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
+        event.preventDefault();
+        this.querySelector("zen-dialog").open();
+        void this.loadBlogDetail(latestBlog.dataset.blogUrl);
       }
     };
     this.addEventListener("click", this._onClick);
@@ -462,13 +510,44 @@
     this.querySelector("[data-preview-type]").textContent = preview.type;
     this.querySelector("[data-dialog-title]").textContent = preview.title;
     this.querySelector("[data-dialog-copy]").textContent = preview.copy;
+    this.renderShowcase(preview.works);
+    const link = this.querySelector("[data-preview-link]");
+    link.href = preview.url;
+    link.setAttribute("aria-label", `Open ${preview.title}`);
+    const resumeDossier = this.querySelector("[data-resume-dossier]");
+    const resumeDownload = this.querySelector("[data-resume-download]");
+    const blogBrowser = this.querySelector("[data-blog-browser]");
+    const isResume = selection === "resume";
+    const isBlog = selection === "blog";
+    resumeDossier.hidden = !isResume;
+    resumeDownload.hidden = !isResume;
+    blogBrowser.hidden = !isBlog;
+    this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog;
+    if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1;
+    if (isBlog) {
+      const status = this.querySelector("[data-blog-status]");
+      status.hidden = false;
+      status.textContent = "Select a blog to inspect.";
+      this.querySelector("[data-blog-content]").replaceChildren();
+      for (const button of this.querySelectorAll("[data-blog-entry]")) {
+        button.setAttribute("aria-pressed", "false");
+      }
+      void this.loadBlogs();
+    }
+  }
+
+  renderShowcase(works, latestBlogs = false) {
     const showcase = this.querySelector("[data-work-showcase]");
-    showcase.replaceChildren(...preview.works.map(work => {
+    showcase.replaceChildren(...works.map(work => {
       const item = document.createElement("li");
       const link = document.createElement("a");
       const label = document.createElement("span");
       const detail = document.createElement("small");
       link.href = work.url;
+      if (latestBlogs) {
+        link.dataset.latestBlog = "";
+        link.dataset.blogUrl = work.url;
+      }
       if (new URL(work.url, window.location.href).origin !== window.location.origin) {
         link.target = "_blank";
         link.rel = "noreferrer";
@@ -479,15 +558,6 @@
       item.append(link);
       return item;
     }));
-    const link = this.querySelector("[data-preview-link]");
-    link.href = preview.url;
-    link.setAttribute("aria-label", `Open ${preview.title}`);
-    const resumeDossier = this.querySelector("[data-resume-dossier]");
-    const resumeDownload = this.querySelector("[data-resume-download]");
-    const isResume = selection === "resume";
-    resumeDossier.hidden = !isResume;
-    resumeDownload.hidden = !isResume;
-    this.querySelector("[data-dialog-copy]").hidden = isResume;
   }
 
   async loadResume() {
@@ -508,25 +578,7 @@
       );
       const resume = documentCopy.querySelector("main");
       if (!resume) throw new Error("Resume content is unavailable");
-      for (const unsafe of resume.querySelectorAll(
-        "embed, iframe, object, script, style, svg",
-      )) {
-        unsafe.remove();
-      }
-      for (const element of resume.querySelectorAll("*")) {
-        for (const attribute of [...element.attributes]) {
-          if (attribute.name.toLowerCase().startsWith("on")) {
-            element.removeAttribute(attribute.name);
-          }
-        }
-      }
-      for (const anchor of resume.querySelectorAll("a")) {
-        const target = new URL(anchor.href, window.location.href);
-        if (target.origin !== window.location.origin) {
-          anchor.target = "_blank";
-          anchor.rel = "noreferrer";
-        }
-      }
+      sanitizeFetchedMain(resume);
       content.replaceChildren(...resume.childNodes);
       status.hidden = true;
       this._resumeLoaded = true;
@@ -536,6 +588,127 @@
       this._resumeLoading = false;
     }
   }
+
+  async loadBlogs() {
+    if (this._blogs) {
+      if (this.dataset.selection === "blog") {
+        this.renderShowcase(this._blogs.slice(0, 4), true);
+      }
+      return true;
+    }
+    if (this._blogsLoading) return this._blogsLoading;
+    const list = this.querySelector("[data-blog-list]");
+    this._blogsLoading = (async () => {
+      const response = await fetch("/blog", {
+        headers: { Accept: "text/html" },
+      });
+      if (!response.ok) {
+        throw new Error(`Blog archive request failed (${response.status})`);
+      }
+      const documentCopy = new DOMParser().parseFromString(
+        await response.text(),
+        "text/html",
+      );
+      this._blogs = [...documentCopy.querySelectorAll(
+        'main li a[href^="/blog/"]',
+      )].map(anchor => ({
+        date: anchor.closest("li")?.querySelector("span")?.textContent.trim() ||
+          "Archive",
+        detail: anchor.closest("li")?.querySelector("span")?.textContent.trim() ||
+          "Archive",
+        label: anchor.textContent.trim(),
+        url: anchor.getAttribute("href"),
+      }));
+      if (!this._blogs.length) throw new Error("No blog entries were found");
+      this.renderBlogList();
+      if (this.dataset.selection === "blog") {
+        this.renderShowcase(this._blogs.slice(0, 4), true);
+      }
+      return true;
+    })().catch(error => {
+      list.replaceChildren();
+      const item = document.createElement("li");
+      item.textContent = `Unable to load blogs: ${error.message}`;
+      list.append(item);
+      return false;
+    }).finally(() => {
+      this._blogsLoading = null;
+    });
+    return this._blogsLoading;
+  }
+
+  renderBlogList() {
+    const list = this.querySelector("[data-blog-list]");
+    list.replaceChildren(...this._blogs.map(blog => {
+      const item = document.createElement("li");
+      const owner = document.createElement("zen-button");
+      const button = document.createElement("button");
+      const title = document.createElement("span");
+      const date = document.createElement("small");
+      owner.setAttribute("appearance", "plain");
+      owner.setAttribute("size", "sm");
+      button.type = "button";
+      button.dataset.blogEntry = "";
+      button.dataset.blogUrl = blog.url;
+      title.textContent = blog.label;
+      date.textContent = blog.date;
+      button.append(title, date);
+      owner.append(button);
+      item.append(owner);
+      return item;
+    }));
+  }
+
+  async loadBlogDetail(url) {
+    if (!await this.loadBlogs()) return;
+    const blog = this._blogs.find(item => item.url === url);
+    const status = this.querySelector("[data-blog-status]");
+    const content = this.querySelector("[data-blog-content]");
+    if (!blog) {
+      status.hidden = false;
+      status.textContent = "Unable to load blog: unknown entry.";
+      return;
+    }
+    const request = (this._blogRequest || 0) + 1;
+    this._blogRequest = request;
+    status.hidden = false;
+    status.textContent = `Loading ${blog.label}...`;
+    content.replaceChildren();
+    this.querySelector("[data-dialog-title]").textContent = blog.label;
+    const fullPage = this.querySelector("[data-preview-link]");
+    fullPage.href = blog.url;
+    fullPage.setAttribute("aria-label", `Open ${blog.label}`);
+    for (const button of this.querySelectorAll("[data-blog-entry]")) {
+      button.setAttribute(
+        "aria-pressed",
+        String(button.dataset.blogUrl === blog.url),
+      );
+    }
+    try {
+      const response = await fetch(blog.url, {
+        headers: { Accept: "text/html" },
+      });
+      if (!response.ok) {
+        throw new Error(`Blog request failed (${response.status})`);
+      }
+      const documentCopy = new DOMParser().parseFromString(
+        await response.text(),
+        "text/html",
+      );
+      const article = documentCopy.querySelector("main");
+      if (!article) throw new Error("Blog content is unavailable");
+      sanitizeFetchedMain(article);
+      if (request !== this._blogRequest || this.dataset.selection !== "blog") {
+        return;
+      }
+      content.replaceChildren(...article.childNodes);
+      status.hidden = true;
+    } catch (error) {
+      if (request !== this._blogRequest) return;
+      status.hidden = false;
+      status.textContent = `Unable to load blog: ${error.message}`;
+    }
+  }
 }
 
 class MjjJrpgShell extends HTMLElement {}