Mercurial
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 {} |