comparison 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
comparison
equal deleted inserted replaced
261:b401627fc49e 262:0f45474c1b1a
433 disconnectedCallback() { 433 disconnectedCallback() {
434 this.removeEventListener("click", this._onClick); 434 this.removeEventListener("click", this._onClick);
435 } 435 }
436 } 436 }
437 437
438 function sanitizeFetchedMain(main) {
439 for (const unsafe of main.querySelectorAll(
440 "embed, form, iframe, object, script, style, svg",
441 )) {
442 unsafe.remove();
443 }
444 for (const element of main.querySelectorAll("*")) {
445 for (const attribute of [...element.attributes]) {
446 if (attribute.name.toLowerCase().startsWith("on")) {
447 element.removeAttribute(attribute.name);
448 }
449 }
450 }
451 for (const anchor of main.querySelectorAll("a")) {
452 const target = new URL(
453 anchor.getAttribute("href") || "",
454 window.location.href,
455 );
456 anchor.href = target.href;
457 if (target.origin !== window.location.origin) {
458 anchor.target = "_blank";
459 anchor.rel = "noreferrer";
460 }
461 }
462 for (const image of main.querySelectorAll("img")) {
463 const source = image.getAttribute("src") || "";
464 if (source.startsWith("/public/") && source.endsWith(".png")) {
465 image.src = source.replace(/\.png$/i, ".webp");
466 }
467 image.loading = "lazy";
468 }
469 return main;
470 }
471
438 class MjjJrpgPreview extends HTMLElement { 472 class MjjJrpgPreview extends HTMLElement {
439 connectedCallback() { 473 connectedCallback() {
440 this._onClick = event => { 474 this._onClick = event => {
441 if ( 475 const trigger = event.target.closest("[data-zen-trigger]");
442 event.target.closest("[data-zen-trigger]") && 476 if (trigger) {
443 this.dataset.selection === "resume" 477 if (this.dataset.selection === "resume") void this.loadResume();
444 ) { 478 if (this.dataset.selection === "blog") {
445 void this.loadResume(); 479 this.show("blog");
480 void this.loadBlogs();
481 }
482 return;
483 }
484 const blogEntry = event.target.closest("[data-blog-entry]");
485 if (blogEntry) {
486 void this.loadBlogDetail(blogEntry.dataset.blogUrl);
487 return;
488 }
489 const latestBlog = event.target.closest("[data-latest-blog]");
490 if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
491 event.preventDefault();
492 this.querySelector("zen-dialog").open();
493 void this.loadBlogDetail(latestBlog.dataset.blogUrl);
446 } 494 }
447 }; 495 };
448 this.addEventListener("click", this._onClick); 496 this.addEventListener("click", this._onClick);
449 this.show(this.dataset.selection || "resume"); 497 this.show(this.dataset.selection || "resume");
450 } 498 }
460 this.querySelector("[data-preview-title]").textContent = preview.title; 508 this.querySelector("[data-preview-title]").textContent = preview.title;
461 this.querySelector("[data-preview-copy]").textContent = preview.copy; 509 this.querySelector("[data-preview-copy]").textContent = preview.copy;
462 this.querySelector("[data-preview-type]").textContent = preview.type; 510 this.querySelector("[data-preview-type]").textContent = preview.type;
463 this.querySelector("[data-dialog-title]").textContent = preview.title; 511 this.querySelector("[data-dialog-title]").textContent = preview.title;
464 this.querySelector("[data-dialog-copy]").textContent = preview.copy; 512 this.querySelector("[data-dialog-copy]").textContent = preview.copy;
513 this.renderShowcase(preview.works);
514 const link = this.querySelector("[data-preview-link]");
515 link.href = preview.url;
516 link.setAttribute("aria-label", `Open ${preview.title}`);
517 const resumeDossier = this.querySelector("[data-resume-dossier]");
518 const resumeDownload = this.querySelector("[data-resume-download]");
519 const blogBrowser = this.querySelector("[data-blog-browser]");
520 const isResume = selection === "resume";
521 const isBlog = selection === "blog";
522 resumeDossier.hidden = !isResume;
523 resumeDownload.hidden = !isResume;
524 blogBrowser.hidden = !isBlog;
525 this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog;
526 if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1;
527 if (isBlog) {
528 const status = this.querySelector("[data-blog-status]");
529 status.hidden = false;
530 status.textContent = "Select a blog to inspect.";
531 this.querySelector("[data-blog-content]").replaceChildren();
532 for (const button of this.querySelectorAll("[data-blog-entry]")) {
533 button.setAttribute("aria-pressed", "false");
534 }
535 void this.loadBlogs();
536 }
537 }
538
539 renderShowcase(works, latestBlogs = false) {
465 const showcase = this.querySelector("[data-work-showcase]"); 540 const showcase = this.querySelector("[data-work-showcase]");
466 showcase.replaceChildren(...preview.works.map(work => { 541 showcase.replaceChildren(...works.map(work => {
467 const item = document.createElement("li"); 542 const item = document.createElement("li");
468 const link = document.createElement("a"); 543 const link = document.createElement("a");
469 const label = document.createElement("span"); 544 const label = document.createElement("span");
470 const detail = document.createElement("small"); 545 const detail = document.createElement("small");
471 link.href = work.url; 546 link.href = work.url;
547 if (latestBlogs) {
548 link.dataset.latestBlog = "";
549 link.dataset.blogUrl = work.url;
550 }
472 if (new URL(work.url, window.location.href).origin !== window.location.origin) { 551 if (new URL(work.url, window.location.href).origin !== window.location.origin) {
473 link.target = "_blank"; 552 link.target = "_blank";
474 link.rel = "noreferrer"; 553 link.rel = "noreferrer";
475 } 554 }
476 label.textContent = work.label; 555 label.textContent = work.label;
477 detail.textContent = work.detail; 556 detail.textContent = work.detail;
478 link.append(label, detail); 557 link.append(label, detail);
479 item.append(link); 558 item.append(link);
480 return item; 559 return item;
481 })); 560 }));
482 const link = this.querySelector("[data-preview-link]");
483 link.href = preview.url;
484 link.setAttribute("aria-label", `Open ${preview.title}`);
485 const resumeDossier = this.querySelector("[data-resume-dossier]");
486 const resumeDownload = this.querySelector("[data-resume-download]");
487 const isResume = selection === "resume";
488 resumeDossier.hidden = !isResume;
489 resumeDownload.hidden = !isResume;
490 this.querySelector("[data-dialog-copy]").hidden = isResume;
491 } 561 }
492 562
493 async loadResume() { 563 async loadResume() {
494 if (this._resumeLoaded || this._resumeLoading) return; 564 if (this._resumeLoaded || this._resumeLoading) return;
495 this._resumeLoading = true; 565 this._resumeLoading = true;
506 await response.text(), 576 await response.text(),
507 "text/html", 577 "text/html",
508 ); 578 );
509 const resume = documentCopy.querySelector("main"); 579 const resume = documentCopy.querySelector("main");
510 if (!resume) throw new Error("Resume content is unavailable"); 580 if (!resume) throw new Error("Resume content is unavailable");
511 for (const unsafe of resume.querySelectorAll( 581 sanitizeFetchedMain(resume);
512 "embed, iframe, object, script, style, svg",
513 )) {
514 unsafe.remove();
515 }
516 for (const element of resume.querySelectorAll("*")) {
517 for (const attribute of [...element.attributes]) {
518 if (attribute.name.toLowerCase().startsWith("on")) {
519 element.removeAttribute(attribute.name);
520 }
521 }
522 }
523 for (const anchor of resume.querySelectorAll("a")) {
524 const target = new URL(anchor.href, window.location.href);
525 if (target.origin !== window.location.origin) {
526 anchor.target = "_blank";
527 anchor.rel = "noreferrer";
528 }
529 }
530 content.replaceChildren(...resume.childNodes); 582 content.replaceChildren(...resume.childNodes);
531 status.hidden = true; 583 status.hidden = true;
532 this._resumeLoaded = true; 584 this._resumeLoaded = true;
533 } catch (error) { 585 } catch (error) {
534 status.textContent = `Unable to load resume: ${error.message}`; 586 status.textContent = `Unable to load resume: ${error.message}`;
535 } finally { 587 } finally {
536 this._resumeLoading = false; 588 this._resumeLoading = false;
589 }
590 }
591
592 async loadBlogs() {
593 if (this._blogs) {
594 if (this.dataset.selection === "blog") {
595 this.renderShowcase(this._blogs.slice(0, 4), true);
596 }
597 return true;
598 }
599 if (this._blogsLoading) return this._blogsLoading;
600 const list = this.querySelector("[data-blog-list]");
601 this._blogsLoading = (async () => {
602 const response = await fetch("/blog", {
603 headers: { Accept: "text/html" },
604 });
605 if (!response.ok) {
606 throw new Error(`Blog archive request failed (${response.status})`);
607 }
608 const documentCopy = new DOMParser().parseFromString(
609 await response.text(),
610 "text/html",
611 );
612 this._blogs = [...documentCopy.querySelectorAll(
613 'main li a[href^="/blog/"]',
614 )].map(anchor => ({
615 date: anchor.closest("li")?.querySelector("span")?.textContent.trim() ||
616 "Archive",
617 detail: anchor.closest("li")?.querySelector("span")?.textContent.trim() ||
618 "Archive",
619 label: anchor.textContent.trim(),
620 url: anchor.getAttribute("href"),
621 }));
622 if (!this._blogs.length) throw new Error("No blog entries were found");
623 this.renderBlogList();
624 if (this.dataset.selection === "blog") {
625 this.renderShowcase(this._blogs.slice(0, 4), true);
626 }
627 return true;
628 })().catch(error => {
629 list.replaceChildren();
630 const item = document.createElement("li");
631 item.textContent = `Unable to load blogs: ${error.message}`;
632 list.append(item);
633 return false;
634 }).finally(() => {
635 this._blogsLoading = null;
636 });
637 return this._blogsLoading;
638 }
639
640 renderBlogList() {
641 const list = this.querySelector("[data-blog-list]");
642 list.replaceChildren(...this._blogs.map(blog => {
643 const item = document.createElement("li");
644 const owner = document.createElement("zen-button");
645 const button = document.createElement("button");
646 const title = document.createElement("span");
647 const date = document.createElement("small");
648 owner.setAttribute("appearance", "plain");
649 owner.setAttribute("size", "sm");
650 button.type = "button";
651 button.dataset.blogEntry = "";
652 button.dataset.blogUrl = blog.url;
653 title.textContent = blog.label;
654 date.textContent = blog.date;
655 button.append(title, date);
656 owner.append(button);
657 item.append(owner);
658 return item;
659 }));
660 }
661
662 async loadBlogDetail(url) {
663 if (!await this.loadBlogs()) return;
664 const blog = this._blogs.find(item => item.url === url);
665 const status = this.querySelector("[data-blog-status]");
666 const content = this.querySelector("[data-blog-content]");
667 if (!blog) {
668 status.hidden = false;
669 status.textContent = "Unable to load blog: unknown entry.";
670 return;
671 }
672 const request = (this._blogRequest || 0) + 1;
673 this._blogRequest = request;
674 status.hidden = false;
675 status.textContent = `Loading ${blog.label}...`;
676 content.replaceChildren();
677 this.querySelector("[data-dialog-title]").textContent = blog.label;
678 const fullPage = this.querySelector("[data-preview-link]");
679 fullPage.href = blog.url;
680 fullPage.setAttribute("aria-label", `Open ${blog.label}`);
681 for (const button of this.querySelectorAll("[data-blog-entry]")) {
682 button.setAttribute(
683 "aria-pressed",
684 String(button.dataset.blogUrl === blog.url),
685 );
686 }
687 try {
688 const response = await fetch(blog.url, {
689 headers: { Accept: "text/html" },
690 });
691 if (!response.ok) {
692 throw new Error(`Blog request failed (${response.status})`);
693 }
694 const documentCopy = new DOMParser().parseFromString(
695 await response.text(),
696 "text/html",
697 );
698 const article = documentCopy.querySelector("main");
699 if (!article) throw new Error("Blog content is unavailable");
700 sanitizeFetchedMain(article);
701 if (request !== this._blogRequest || this.dataset.selection !== "blog") {
702 return;
703 }
704 content.replaceChildren(...article.childNodes);
705 status.hidden = true;
706 } catch (error) {
707 if (request !== this._blogRequest) return;
708 status.hidden = false;
709 status.textContent = `Unable to load blog: ${error.message}`;
537 } 710 }
538 } 711 }
539 } 712 }
540 713
541 class MjjJrpgShell extends HTMLElement {} 714 class MjjJrpgShell extends HTMLElement {}