comparison mrjunejune/src/jrpg/jrpg.js @ 273:e02e2036ef84 default tip

add Layer 2 JRPG component system Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Sat, 08 Aug 2026 02:08:08 -0700
parents 41a49c29a28f
children
comparison
equal deleted inserted replaced
272:41a49c29a28f 273:e02e2036ef84
541 return main; 541 return main;
542 } 542 }
543 543
544 class MjjJrpgPreview extends HTMLElement { 544 class MjjJrpgPreview extends HTMLElement {
545 connectedCallback() { 545 connectedCallback() {
546 this._modalOwner = this.querySelector("[data-detail-modal-owner]");
546 this._dialog = this.querySelector("dialog"); 547 this._dialog = this.querySelector("dialog");
547 this._onDialogClose = () => { 548 this._onDialogClose = () => {
548 this._toolRequest = (this._toolRequest || 0) + 1; 549 this._toolRequest = (this._toolRequest || 0) + 1;
549 this.cleanupTool(); 550 this.cleanupTool();
550 }; 551 };
568 return; 569 return;
569 } 570 }
570 const latestBlog = event.target.closest("[data-latest-blog]"); 571 const latestBlog = event.target.closest("[data-latest-blog]");
571 if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) { 572 if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
572 event.preventDefault(); 573 event.preventDefault();
573 this.querySelector("zen-dialog").open(); 574 this.show("blog");
575 this._modalOwner?.open();
574 void this.loadBlogDetail(latestBlog.dataset.blogUrl); 576 void this.loadBlogDetail(latestBlog.dataset.blogUrl);
575 return; 577 return;
576 } 578 }
577 const blogArchive = event.target.closest("[data-blog-archive]"); 579 const blogArchive = event.target.closest("[data-blog-archive]");
578 if (blogArchive) { 580 if (blogArchive) {
579 event.preventDefault(); 581 event.preventDefault();
580 this.querySelector("zen-dialog").open();
581 this.show("blog"); 582 this.show("blog");
583 this._modalOwner?.open();
582 void this.loadBlogs(); 584 void this.loadBlogs();
583 return; 585 return;
584 } 586 }
585 const resumeModal = event.target.closest("[data-resume-modal]"); 587 const resumeModal = event.target.closest("[data-resume-modal]");
586 if (resumeModal) { 588 if (resumeModal) {
587 event.preventDefault(); 589 event.preventDefault();
588 this.querySelector("zen-dialog").open();
589 this.show("resume"); 590 this.show("resume");
591 this._modalOwner?.open();
590 void this.loadResume(); 592 void this.loadResume();
591 return; 593 return;
592 } 594 }
593 const latestTool = event.target.closest("[data-latest-tool]"); 595 const latestTool = event.target.closest("[data-latest-tool]");
594 if (latestTool && !event.metaKey && !event.ctrlKey && !event.shiftKey) { 596 if (latestTool && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
595 event.preventDefault(); 597 event.preventDefault();
596 this.querySelector("zen-dialog").open(); 598 this.show("tools");
599 this._modalOwner?.open();
597 void this.loadToolDetail(latestTool.dataset.toolUrl); 600 void this.loadToolDetail(latestTool.dataset.toolUrl);
598 } 601 }
599 }; 602 };
600 this.addEventListener("click", this._onClick); 603 this.addEventListener("click", this._onClick);
601 this._dialog.addEventListener("close", this._onDialogClose); 604 this._dialog.addEventListener("close", this._onDialogClose);
604 607
605 disconnectedCallback() { 608 disconnectedCallback() {
606 this.removeEventListener("click", this._onClick); 609 this.removeEventListener("click", this._onClick);
607 this._dialog?.removeEventListener("close", this._onDialogClose); 610 this._dialog?.removeEventListener("close", this._onDialogClose);
608 this.cleanupTool(); 611 this.cleanupTool();
612 }
613
614 _setDetailModalType(useWindow) {
615 const targetName = useWindow ? "mjj-window-modal" : "mjj-content-modal";
616 if (!this._modalOwner || this._modalOwner.localName === targetName) return;
617 const replacement = document.createElement(targetName);
618 for (const attribute of [...this._modalOwner.attributes]) {
619 replacement.setAttribute(attribute.name, attribute.value);
620 }
621 replacement.append(...this._modalOwner.childNodes);
622 this._modalOwner.replaceWith(replacement);
623 this._modalOwner = replacement;
609 } 624 }
610 625
611 show(selection) { 626 show(selection) {
612 this.cleanupTool(); 627 this.cleanupTool();
613 const preview = PREVIEWS[selection] || PREVIEWS.resume; 628 const preview = PREVIEWS[selection] || PREVIEWS.resume;
615 this.querySelector("[data-preview-title]").textContent = preview.title; 630 this.querySelector("[data-preview-title]").textContent = preview.title;
616 this.querySelector("[data-preview-copy]").textContent = preview.copy; 631 this.querySelector("[data-preview-copy]").textContent = preview.copy;
617 this.querySelector("[data-dialog-title]").textContent = preview.title; 632 this.querySelector("[data-dialog-title]").textContent = preview.title;
618 this.querySelector("[data-dialog-copy]").textContent = preview.copy; 633 this.querySelector("[data-dialog-copy]").textContent = preview.copy;
619 this.renderShowcase(preview.works); 634 this.renderShowcase(preview.works);
620 const link = this.querySelector("[data-preview-link]");
621 link.href = preview.url;
622 link.setAttribute("aria-label", `Open ${preview.title}`);
623 const resumeDossier = this.querySelector("[data-resume-dossier]"); 635 const resumeDossier = this.querySelector("[data-resume-dossier]");
624 const resumeDownload = this.querySelector("[data-resume-download]"); 636 const resumeDownload = this.querySelector("[data-resume-download]");
625 const blogBrowser = this.querySelector("[data-blog-browser]"); 637 const blogBrowser = this.querySelector("[data-blog-browser]");
626 const toolBrowser = this.querySelector("[data-tool-browser]"); 638 const toolBrowser = this.querySelector("[data-tool-browser]");
627 const isResume = selection === "resume"; 639 const isResume = selection === "resume";
628 const isBlog = selection === "blog"; 640 const isBlog = selection === "blog";
629 const isTools = selection === "tools"; 641 const isTools = selection === "tools";
642 this._setDetailModalType(isTools);
630 this._dialog.dataset.detailMode = isTools ? "tools" : "content"; 643 this._dialog.dataset.detailMode = isTools ? "tools" : "content";
631 resumeDossier.hidden = !isResume; 644 resumeDossier.hidden = !isResume;
632 resumeDownload.hidden = !isResume; 645 resumeDownload.hidden = !isResume;
633 this.querySelector("[data-dialog-actions]").hidden = !isResume;
634 blogBrowser.hidden = !isBlog; 646 blogBrowser.hidden = !isBlog;
635 toolBrowser.hidden = !isTools; 647 toolBrowser.hidden = !isTools;
636 this.querySelector("[data-dialog-copy]").hidden = 648 this.querySelector("[data-dialog-copy]").hidden =
637 isResume || isBlog || isTools; 649 isResume || isBlog || isTools;
638 if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; 650 if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1;
709 "text/html", 721 "text/html",
710 ); 722 );
711 const resume = documentCopy.querySelector("main"); 723 const resume = documentCopy.querySelector("main");
712 if (!resume) throw new Error("Resume content is unavailable"); 724 if (!resume) throw new Error("Resume content is unavailable");
713 sanitizeFetchedMain(resume); 725 sanitizeFetchedMain(resume);
726 for (const duplicateDownload of resume.querySelectorAll(
727 'a[href="/public/resume.pdf"]',
728 )) {
729 duplicateDownload.closest("zen-button")?.remove();
730 duplicateDownload.remove();
731 }
714 content.replaceChildren(...resume.childNodes); 732 content.replaceChildren(...resume.childNodes);
715 status.hidden = true; 733 status.hidden = true;
716 this._resumeLoaded = true; 734 this._resumeLoaded = true;
717 } catch (error) { 735 } catch (error) {
718 status.textContent = `Unable to load resume: ${error.message}`; 736 status.textContent = `Unable to load resume: ${error.message}`;
821 this._blogRequest = request; 839 this._blogRequest = request;
822 status.hidden = false; 840 status.hidden = false;
823 status.textContent = `Loading ${blog.label}...`; 841 status.textContent = `Loading ${blog.label}...`;
824 content.replaceChildren(); 842 content.replaceChildren();
825 this.querySelector("[data-dialog-title]").textContent = blog.label; 843 this.querySelector("[data-dialog-title]").textContent = blog.label;
826 const fullPage = this.querySelector("[data-preview-link]");
827 fullPage.href = blog.url;
828 fullPage.setAttribute("aria-label", `Open ${blog.label}`);
829 for (const button of this.querySelectorAll("[data-blog-entry]")) { 844 for (const button of this.querySelectorAll("[data-blog-entry]")) {
830 button.setAttribute( 845 button.setAttribute(
831 "aria-pressed", 846 "aria-pressed",
832 String(button.dataset.blogUrl === blog.url), 847 String(button.dataset.blogUrl === blog.url),
833 ); 848 );
926 status.hidden = false; 941 status.hidden = false;
927 status.textContent = `Loading ${tool.label}...`; 942 status.textContent = `Loading ${tool.label}...`;
928 this.cleanupTool(); 943 this.cleanupTool();
929 content.replaceChildren(); 944 content.replaceChildren();
930 this.querySelector("[data-dialog-title]").textContent = tool.label; 945 this.querySelector("[data-dialog-title]").textContent = tool.label;
931 const fullPage = this.querySelector("[data-preview-link]");
932 fullPage.href = tool.url;
933 fullPage.setAttribute("aria-label", `Open ${tool.label}`);
934 if (tool.url === "/tools/markdown_to_html") { 946 if (tool.url === "/tools/markdown_to_html") {
935 await this.renderMarkdownTool(content, status, request); 947 await this.renderMarkdownTool(content, status, request);
936 return; 948 return;
937 } 949 }
938 if (tool.url === "/tools/file_converter") { 950 if (tool.url === "/tools/file_converter") {
1424 this._status = this.querySelector("[data-archive-status]"); 1436 this._status = this.querySelector("[data-archive-status]");
1425 this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]"); 1437 this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]");
1426 this._loadMoreButton = this.querySelector("[data-archive-load-more]"); 1438 this._loadMoreButton = this.querySelector("[data-archive-load-more]");
1427 this._newButton = this.querySelector("[data-archive-new]"); 1439 this._newButton = this.querySelector("[data-archive-new]");
1428 this._closeButton = this.querySelector("[data-archive-close]"); 1440 this._closeButton = this.querySelector("[data-archive-close]");
1441 this._deleteModal = this.querySelector("[data-archive-delete-owner]");
1429 this._deleteDialog = this.querySelector("[data-archive-delete-dialog]"); 1442 this._deleteDialog = this.querySelector("[data-archive-delete-dialog]");
1430 this._deleteNameEl = this.querySelector("[data-archive-delete-name]"); 1443 this._deleteNameEl = this.querySelector("[data-archive-delete-name]");
1431 this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]"); 1444 this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]");
1432 this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]"); 1445 this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]");
1446 this._renameModal = this.querySelector("[data-archive-rename-owner]");
1433 this._renameDialog = this.querySelector("[data-archive-rename-dialog]"); 1447 this._renameDialog = this.querySelector("[data-archive-rename-dialog]");
1434 this._renameInput = this.querySelector("[data-archive-rename-input]"); 1448 this._renameInput = this.querySelector("[data-archive-rename-input]");
1435 this._renameForm = this.querySelector("[data-archive-rename-form]"); 1449 this._renameForm = this.querySelector("[data-archive-rename-form]");
1436 this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]"); 1450 this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]");
1437 this._pendingDeleteId = null; 1451 this._pendingDeleteId = null;
1450 this._loadMoreButton?.addEventListener("click", () => { 1464 this._loadMoreButton?.addEventListener("click", () => {
1451 this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true })); 1465 this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true }));
1452 }); 1466 });
1453 1467
1454 this._deleteCancelButton?.addEventListener("click", () => { 1468 this._deleteCancelButton?.addEventListener("click", () => {
1455 this._deleteDialog?.close(); 1469 this._deleteModal?.close();
1456 const t = this._pendingDeleteTrigger; 1470 const t = this._pendingDeleteTrigger;
1457 this._pendingDeleteId = null; 1471 this._pendingDeleteId = null;
1458 this._pendingDeleteTrigger = null; 1472 this._pendingDeleteTrigger = null;
1459 t?.focus(); 1473 t?.focus();
1460 }); 1474 });
1461 1475
1462 this._deleteConfirmButton?.addEventListener("click", () => { 1476 this._deleteConfirmButton?.addEventListener("click", () => {
1463 const id = this._pendingDeleteId; 1477 const id = this._pendingDeleteId;
1464 const trigger = this._pendingDeleteTrigger; 1478 const trigger = this._pendingDeleteTrigger;
1465 this._deleteDialog?.close(); 1479 this._deleteModal?.close();
1466 this._pendingDeleteId = null; 1480 this._pendingDeleteId = null;
1467 this._pendingDeleteTrigger = null; 1481 this._pendingDeleteTrigger = null;
1468 if (id) { 1482 if (id) {
1469 this.dispatchEvent(new CustomEvent("mjj-archive-delete", { 1483 this.dispatchEvent(new CustomEvent("mjj-archive-delete", {
1470 bubbles: true, 1484 bubbles: true,
1479 this._pendingDeleteTrigger = null; 1493 this._pendingDeleteTrigger = null;
1480 t?.focus(); 1494 t?.focus();
1481 }); 1495 });
1482 1496
1483 this._renameCancelButton?.addEventListener("click", () => { 1497 this._renameCancelButton?.addEventListener("click", () => {
1484 this._renameDialog?.close(); 1498 this._renameModal?.close();
1485 const t = this._pendingRenameTrigger; 1499 const t = this._pendingRenameTrigger;
1486 this._pendingRenameId = null; 1500 this._pendingRenameId = null;
1487 this._pendingRenameTrigger = null; 1501 this._pendingRenameTrigger = null;
1488 t?.focus(); 1502 t?.focus();
1489 }); 1503 });
1492 event.preventDefault(); 1506 event.preventDefault();
1493 const id = this._pendingRenameId; 1507 const id = this._pendingRenameId;
1494 const trigger = this._pendingRenameTrigger; 1508 const trigger = this._pendingRenameTrigger;
1495 const newTitle = this._renameInput?.value.trim(); 1509 const newTitle = this._renameInput?.value.trim();
1496 if (!id || !newTitle) return; 1510 if (!id || !newTitle) return;
1497 this._renameDialog?.close(); 1511 this._renameModal?.close();
1498 this._pendingRenameId = null; 1512 this._pendingRenameId = null;
1499 this._pendingRenameTrigger = null; 1513 this._pendingRenameTrigger = null;
1500 this.dispatchEvent(new CustomEvent("mjj-archive-rename", { 1514 this.dispatchEvent(new CustomEvent("mjj-archive-rename", {
1501 bubbles: true, 1515 bubbles: true,
1502 detail: { id, newTitle, trigger }, 1516 detail: { id, newTitle, trigger },
1527 const item = renameButton.closest("[data-conv-item]"); 1541 const item = renameButton.closest("[data-conv-item]");
1528 if (item?._convId) { 1542 if (item?._convId) {
1529 this._pendingRenameId = item._convId; 1543 this._pendingRenameId = item._convId;
1530 this._pendingRenameTrigger = renameButton; 1544 this._pendingRenameTrigger = renameButton;
1531 if (this._renameInput) this._renameInput.value = item._convTitle || ""; 1545 if (this._renameInput) this._renameInput.value = item._convTitle || "";
1532 this._renameDialog?.showModal(); 1546 this._renameModal?.open();
1533 requestAnimationFrame(() => { this._renameInput?.select(); }); 1547 requestAnimationFrame(() => { this._renameInput?.select(); });
1534 } 1548 }
1535 return; 1549 return;
1536 } 1550 }
1537 const deleteButton = event.target.closest("[data-conv-delete]"); 1551 const deleteButton = event.target.closest("[data-conv-delete]");
1539 const item = deleteButton.closest("[data-conv-item]"); 1553 const item = deleteButton.closest("[data-conv-item]");
1540 if (item?._convId) { 1554 if (item?._convId) {
1541 this._pendingDeleteId = item._convId; 1555 this._pendingDeleteId = item._convId;
1542 this._pendingDeleteTrigger = deleteButton; 1556 this._pendingDeleteTrigger = deleteButton;
1543 if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || ""; 1557 if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || "";
1544 this._deleteDialog?.showModal(); 1558 this._deleteModal?.open();
1545 requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); }); 1559 requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); });
1546 } 1560 }
1547 return; 1561 return;
1548 } 1562 }
1549 }; 1563 };
2184 const minimizeButton = shell?.querySelector("[data-frame-minimize]"); 2198 const minimizeButton = shell?.querySelector("[data-frame-minimize]");
2185 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); 2199 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]");
2186 2200
2187 let characterTimer = 0; 2201 let characterTimer = 0;
2188 let currentConversationId = null; 2202 let currentConversationId = null;
2189 let currentPanel = "resume"; 2203 const serverInitialPanel = VALID_PANELS.includes(shell?.dataset.initialPanel)
2204 ? shell.dataset.initialPanel
2205 : "resume";
2206 let currentPanel = serverInitialPanel;
2190 let archiveCursor = null; 2207 let archiveCursor = null;
2191 let activeController = null; 2208 let activeController = null;
2192 let archiveLoadInFlight = false; /* serialize pagination requests */ 2209 let archiveLoadInFlight = false; /* serialize pagination requests */
2193 let openSeq = 0; /* sequence guard for conversation open */ 2210 let openSeq = 0; /* sequence guard for conversation open */
2194 let popSeq = 0; /* sequence guard for popstate */ 2211 let popSeq = 0; /* sequence guard for popstate */
2797 { panel: validPanel, conversation: validConvId }, 2814 { panel: validPanel, conversation: validConvId },
2798 "", 2815 "",
2799 normalUrl, 2816 normalUrl,
2800 ); 2817 );
2801 } 2818 }
2802 currentPanel = validPanel || "resume"; 2819 currentPanel = validPanel || serverInitialPanel;
2803 } 2820 }
2804 2821
2805 /* ---- Load archive; only an explicit URL restores a conversation ---- */ 2822 /* ---- Load archive; only an explicit URL restores a conversation ---- */
2806 const startupEpoch = principalEpoch; 2823 const startupEpoch = principalEpoch;
2807 const ensureStartupPrincipal = () => { 2824 const ensureStartupPrincipal = () => {