Mercurial
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 = () => { |