Mercurial
comparison mrjunejune/src/jrpg/jrpg.js @ 263:ee04e4e69fed
Add functional JRPG frame and tools
Add full-screen background_2 apertures, functional frame chrome, card-driven details, dual-window tools, live conversion workflows, and bounded cleanup for generated downloads.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Thu, 06 Aug 2026 11:31:30 -0700 |
| parents | 0f45474c1b1a |
| children | 04fee26ecce0 |
comparison
equal
deleted
inserted
replaced
| 262:0f45474c1b1a | 263:ee04e4e69fed |
|---|---|
| 16 const PREVIEWS = Object.freeze({ | 16 const PREVIEWS = Object.freeze({ |
| 17 resume: { | 17 resume: { |
| 18 copy: "Experience, projects, and the systems I have helped build.", | 18 copy: "Experience, projects, and the systems I have helped build.", |
| 19 kicker: "CHARACTER RECORD", | 19 kicker: "CHARACTER RECORD", |
| 20 title: "Resume", | 20 title: "Resume", |
| 21 type: "Profile", | |
| 22 url: "/resume", | 21 url: "/resume", |
| 23 works: [ | 22 works: [ |
| 23 { | |
| 24 detail: "Career dossier", | |
| 25 label: "Full resume", | |
| 26 modal: "resume", | |
| 27 url: "/resume", | |
| 28 }, | |
| 24 { | 29 { |
| 25 detail: "Agentic execution", | 30 detail: "Agentic execution", |
| 26 label: "Copilot Tasks", | 31 label: "Copilot Tasks", |
| 27 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", | 32 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", |
| 28 }, | 33 }, |
| 55 }, | 60 }, |
| 56 tools: { | 61 tools: { |
| 57 copy: "Small, focused utilities for writing, media, and experimentation.", | 62 copy: "Small, focused utilities for writing, media, and experimentation.", |
| 58 kicker: "ITEM INVENTORY", | 63 kicker: "ITEM INVENTORY", |
| 59 title: "Tools", | 64 title: "Tools", |
| 60 type: "Utilities", | |
| 61 url: "/tools", | 65 url: "/tools", |
| 62 works: [ | 66 works: [ |
| 63 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, | 67 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, |
| 64 { detail: "Media", label: "Converter", url: "/tools/file_converter" }, | 68 { detail: "Media", label: "Converter", url: "/tools/file_converter" }, |
| 65 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, | 69 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, |
| 67 }, | 71 }, |
| 68 blog: { | 72 blog: { |
| 69 copy: "Notes from building systems, games, web tools, and curious prototypes.", | 73 copy: "Notes from building systems, games, web tools, and curious prototypes.", |
| 70 kicker: "QUEST ARCHIVE", | 74 kicker: "QUEST ARCHIVE", |
| 71 title: "Blogs", | 75 title: "Blogs", |
| 72 type: "Writing", | |
| 73 url: "/blog", | 76 url: "/blog", |
| 74 works: [ | 77 works: [ |
| 75 { detail: "Archive", label: "All posts", url: "/blog" }, | 78 { detail: "Archive", label: "All posts", url: "/blog" }, |
| 76 ], | 79 ], |
| 77 }, | 80 }, |
| 78 }); | 81 }); |
| 79 | 82 |
| 80 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; | 83 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; |
| 81 const TYPING_INTERVAL_MS = 18; | 84 const TYPING_INTERVAL_MS = 18; |
| 85 const SCRIPT_LOADS = new Map(); | |
| 86 const TOOL_ICON_NAMES = Object.freeze({ | |
| 87 "/notes": "repository", | |
| 88 "/tools/file_converter": "retry", | |
| 89 "/tools/hls_player": "play", | |
| 90 "/tools/latex_editor": "file", | |
| 91 "/tools/markdown_to_html": "code", | |
| 92 }); | |
| 93 | |
| 94 function loadScript(source) { | |
| 95 if (SCRIPT_LOADS.has(source)) return SCRIPT_LOADS.get(source); | |
| 96 const loading = new Promise((resolve, reject) => { | |
| 97 const script = document.createElement("script"); | |
| 98 script.src = source; | |
| 99 script.addEventListener("load", resolve, { once: true }); | |
| 100 script.addEventListener("error", () => { | |
| 101 SCRIPT_LOADS.delete(source); | |
| 102 script.remove(); | |
| 103 reject(new Error(`Unable to load ${source}`)); | |
| 104 }, { once: true }); | |
| 105 document.head.append(script); | |
| 106 }); | |
| 107 SCRIPT_LOADS.set(source, loading); | |
| 108 return loading; | |
| 109 } | |
| 82 | 110 |
| 83 class StreamingTextAnimator { | 111 class StreamingTextAnimator { |
| 84 constructor(render) { | 112 constructor(render) { |
| 85 this._render = render; | 113 this._render = render; |
| 86 this._displayed = ""; | 114 this._displayed = ""; |
| 441 )) { | 469 )) { |
| 442 unsafe.remove(); | 470 unsafe.remove(); |
| 443 } | 471 } |
| 444 for (const element of main.querySelectorAll("*")) { | 472 for (const element of main.querySelectorAll("*")) { |
| 445 for (const attribute of [...element.attributes]) { | 473 for (const attribute of [...element.attributes]) { |
| 446 if (attribute.name.toLowerCase().startsWith("on")) { | 474 const name = attribute.name.toLowerCase(); |
| 475 if (name.startsWith("on") || name === "id" || name === "style") { | |
| 447 element.removeAttribute(attribute.name); | 476 element.removeAttribute(attribute.name); |
| 448 } | 477 } |
| 449 } | 478 } |
| 450 } | 479 } |
| 451 for (const anchor of main.querySelectorAll("a")) { | 480 for (const anchor of main.querySelectorAll("a")) { |
| 481 const href = anchor.getAttribute("href"); | |
| 482 if (!href || anchor.getAttribute("aria-disabled") === "true") { | |
| 483 anchor.remove(); | |
| 484 continue; | |
| 485 } | |
| 452 const target = new URL( | 486 const target = new URL( |
| 453 anchor.getAttribute("href") || "", | 487 href, |
| 454 window.location.href, | 488 window.location.href, |
| 455 ); | 489 ); |
| 456 anchor.href = target.href; | 490 anchor.href = target.href; |
| 457 if (target.origin !== window.location.origin) { | 491 if (target.origin !== window.location.origin) { |
| 458 anchor.target = "_blank"; | 492 anchor.target = "_blank"; |
| 467 image.loading = "lazy"; | 501 image.loading = "lazy"; |
| 468 } | 502 } |
| 469 return main; | 503 return main; |
| 470 } | 504 } |
| 471 | 505 |
| 506 function sanitizeToolDetail(main) { | |
| 507 sanitizeFetchedMain(main); | |
| 508 for (const textarea of main.querySelectorAll("textarea")) { | |
| 509 const sample = document.createElement("pre"); | |
| 510 sample.className = "jrpg-tool-source-sample"; | |
| 511 sample.textContent = textarea.value || textarea.textContent; | |
| 512 textarea.replaceWith(sample); | |
| 513 } | |
| 514 for (const inactive of main.querySelectorAll( | |
| 515 "button, canvas, dialog, input, select, video, [hidden]", | |
| 516 )) { | |
| 517 inactive.remove(); | |
| 518 } | |
| 519 for (const owner of main.querySelectorAll( | |
| 520 "zen-button, zen-checkbox, zen-field, zen-input, zen-textarea", | |
| 521 )) { | |
| 522 owner.replaceWith(...owner.childNodes); | |
| 523 } | |
| 524 for (const label of main.querySelectorAll("label[for]")) { | |
| 525 label.removeAttribute("for"); | |
| 526 } | |
| 527 return main; | |
| 528 } | |
| 529 | |
| 472 class MjjJrpgPreview extends HTMLElement { | 530 class MjjJrpgPreview extends HTMLElement { |
| 473 connectedCallback() { | 531 connectedCallback() { |
| 532 this._dialog = this.querySelector("dialog"); | |
| 533 this._onDialogClose = () => { | |
| 534 this._toolRequest = (this._toolRequest || 0) + 1; | |
| 535 this.cleanupTool(); | |
| 536 }; | |
| 474 this._onClick = event => { | 537 this._onClick = event => { |
| 475 const trigger = event.target.closest("[data-zen-trigger]"); | 538 const trigger = event.target.closest("[data-zen-trigger]"); |
| 476 if (trigger) { | 539 if (trigger) { |
| 477 if (this.dataset.selection === "resume") void this.loadResume(); | 540 if (this.dataset.selection === "resume") void this.loadResume(); |
| 478 if (this.dataset.selection === "blog") { | 541 if (this.dataset.selection === "blog") { |
| 479 this.show("blog"); | 542 this.show("blog"); |
| 480 void this.loadBlogs(); | 543 void this.loadBlogs(); |
| 481 } | 544 } |
| 545 if (this.dataset.selection === "tools") { | |
| 546 this.show("tools"); | |
| 547 void this.openToolWorkspace(); | |
| 548 } | |
| 482 return; | 549 return; |
| 483 } | 550 } |
| 484 const blogEntry = event.target.closest("[data-blog-entry]"); | 551 const blogEntry = event.target.closest("[data-blog-entry]"); |
| 485 if (blogEntry) { | 552 if (blogEntry) { |
| 486 void this.loadBlogDetail(blogEntry.dataset.blogUrl); | 553 void this.loadBlogDetail(blogEntry.dataset.blogUrl); |
| 489 const latestBlog = event.target.closest("[data-latest-blog]"); | 556 const latestBlog = event.target.closest("[data-latest-blog]"); |
| 490 if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) { | 557 if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) { |
| 491 event.preventDefault(); | 558 event.preventDefault(); |
| 492 this.querySelector("zen-dialog").open(); | 559 this.querySelector("zen-dialog").open(); |
| 493 void this.loadBlogDetail(latestBlog.dataset.blogUrl); | 560 void this.loadBlogDetail(latestBlog.dataset.blogUrl); |
| 561 return; | |
| 562 } | |
| 563 const blogArchive = event.target.closest("[data-blog-archive]"); | |
| 564 if (blogArchive) { | |
| 565 event.preventDefault(); | |
| 566 this.querySelector("zen-dialog").open(); | |
| 567 this.show("blog"); | |
| 568 void this.loadBlogs(); | |
| 569 return; | |
| 570 } | |
| 571 const resumeModal = event.target.closest("[data-resume-modal]"); | |
| 572 if (resumeModal) { | |
| 573 event.preventDefault(); | |
| 574 this.querySelector("zen-dialog").open(); | |
| 575 this.show("resume"); | |
| 576 void this.loadResume(); | |
| 577 return; | |
| 578 } | |
| 579 const latestTool = event.target.closest("[data-latest-tool]"); | |
| 580 if (latestTool && !event.metaKey && !event.ctrlKey && !event.shiftKey) { | |
| 581 event.preventDefault(); | |
| 582 this.querySelector("zen-dialog").open(); | |
| 583 void this.loadToolDetail(latestTool.dataset.toolUrl); | |
| 494 } | 584 } |
| 495 }; | 585 }; |
| 496 this.addEventListener("click", this._onClick); | 586 this.addEventListener("click", this._onClick); |
| 587 this._dialog.addEventListener("close", this._onDialogClose); | |
| 497 this.show(this.dataset.selection || "resume"); | 588 this.show(this.dataset.selection || "resume"); |
| 498 } | 589 } |
| 499 | 590 |
| 500 disconnectedCallback() { | 591 disconnectedCallback() { |
| 501 this.removeEventListener("click", this._onClick); | 592 this.removeEventListener("click", this._onClick); |
| 593 this._dialog?.removeEventListener("close", this._onDialogClose); | |
| 594 this.cleanupTool(); | |
| 502 } | 595 } |
| 503 | 596 |
| 504 show(selection) { | 597 show(selection) { |
| 598 this.cleanupTool(); | |
| 505 const preview = PREVIEWS[selection] || PREVIEWS.resume; | 599 const preview = PREVIEWS[selection] || PREVIEWS.resume; |
| 506 this.dataset.selection = selection; | 600 this.dataset.selection = selection; |
| 507 this.querySelector("[data-preview-kicker]").textContent = preview.kicker; | 601 this.querySelector("[data-preview-kicker]").textContent = preview.kicker; |
| 508 this.querySelector("[data-preview-title]").textContent = preview.title; | 602 this.querySelector("[data-preview-title]").textContent = preview.title; |
| 509 this.querySelector("[data-preview-copy]").textContent = preview.copy; | 603 this.querySelector("[data-preview-copy]").textContent = preview.copy; |
| 510 this.querySelector("[data-preview-type]").textContent = preview.type; | |
| 511 this.querySelector("[data-dialog-title]").textContent = preview.title; | 604 this.querySelector("[data-dialog-title]").textContent = preview.title; |
| 512 this.querySelector("[data-dialog-copy]").textContent = preview.copy; | 605 this.querySelector("[data-dialog-copy]").textContent = preview.copy; |
| 513 this.renderShowcase(preview.works); | 606 this.renderShowcase(preview.works); |
| 514 const link = this.querySelector("[data-preview-link]"); | 607 const link = this.querySelector("[data-preview-link]"); |
| 515 link.href = preview.url; | 608 link.href = preview.url; |
| 516 link.setAttribute("aria-label", `Open ${preview.title}`); | 609 link.setAttribute("aria-label", `Open ${preview.title}`); |
| 517 const resumeDossier = this.querySelector("[data-resume-dossier]"); | 610 const resumeDossier = this.querySelector("[data-resume-dossier]"); |
| 518 const resumeDownload = this.querySelector("[data-resume-download]"); | 611 const resumeDownload = this.querySelector("[data-resume-download]"); |
| 519 const blogBrowser = this.querySelector("[data-blog-browser]"); | 612 const blogBrowser = this.querySelector("[data-blog-browser]"); |
| 613 const toolBrowser = this.querySelector("[data-tool-browser]"); | |
| 520 const isResume = selection === "resume"; | 614 const isResume = selection === "resume"; |
| 521 const isBlog = selection === "blog"; | 615 const isBlog = selection === "blog"; |
| 616 const isTools = selection === "tools"; | |
| 617 this._dialog.dataset.detailMode = isTools ? "tools" : "content"; | |
| 522 resumeDossier.hidden = !isResume; | 618 resumeDossier.hidden = !isResume; |
| 523 resumeDownload.hidden = !isResume; | 619 resumeDownload.hidden = !isResume; |
| 620 this.querySelector("[data-dialog-actions]").hidden = !isResume; | |
| 524 blogBrowser.hidden = !isBlog; | 621 blogBrowser.hidden = !isBlog; |
| 525 this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog; | 622 toolBrowser.hidden = !isTools; |
| 623 this.querySelector("[data-dialog-copy]").hidden = | |
| 624 isResume || isBlog || isTools; | |
| 526 if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; | 625 if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; |
| 527 if (isBlog) { | 626 if (isBlog) { |
| 528 const status = this.querySelector("[data-blog-status]"); | 627 const status = this.querySelector("[data-blog-status]"); |
| 529 status.hidden = false; | 628 status.hidden = false; |
| 530 status.textContent = "Select a blog to inspect."; | 629 status.textContent = "Select a blog to inspect."; |
| 532 for (const button of this.querySelectorAll("[data-blog-entry]")) { | 631 for (const button of this.querySelectorAll("[data-blog-entry]")) { |
| 533 button.setAttribute("aria-pressed", "false"); | 632 button.setAttribute("aria-pressed", "false"); |
| 534 } | 633 } |
| 535 void this.loadBlogs(); | 634 void this.loadBlogs(); |
| 536 } | 635 } |
| 537 } | 636 if (!isTools) this._toolRequest = (this._toolRequest || 0) + 1; |
| 538 | 637 if (isTools) { |
| 539 renderShowcase(works, latestBlogs = false) { | 638 const status = this.querySelector("[data-tool-status]"); |
| 639 status.hidden = false; | |
| 640 status.textContent = "Select a tool to inspect."; | |
| 641 this.querySelector("[data-tool-content]").replaceChildren(); | |
| 642 void this.loadTools(); | |
| 643 } | |
| 644 } | |
| 645 | |
| 646 renderShowcase(works, latestBlogs = false, latestKind = null) { | |
| 540 const showcase = this.querySelector("[data-work-showcase]"); | 647 const showcase = this.querySelector("[data-work-showcase]"); |
| 541 showcase.replaceChildren(...works.map(work => { | 648 showcase.replaceChildren(...works.map(work => { |
| 542 const item = document.createElement("li"); | 649 const item = document.createElement("li"); |
| 543 const link = document.createElement("a"); | 650 const link = document.createElement("a"); |
| 544 const label = document.createElement("span"); | 651 const label = document.createElement("span"); |
| 545 const detail = document.createElement("small"); | 652 const detail = document.createElement("small"); |
| 546 link.href = work.url; | 653 link.href = work.url; |
| 547 if (latestBlogs) { | 654 if (latestBlogs) { |
| 548 link.dataset.latestBlog = ""; | 655 if (work.modal === "blog") { |
| 549 link.dataset.blogUrl = work.url; | 656 link.dataset.blogArchive = ""; |
| 657 } else { | |
| 658 link.dataset.latestBlog = ""; | |
| 659 link.dataset.blogUrl = work.url; | |
| 660 } | |
| 661 } | |
| 662 if (work.modal === "resume") link.dataset.resumeModal = ""; | |
| 663 if (latestKind === "tool") { | |
| 664 link.dataset.latestTool = ""; | |
| 665 link.dataset.toolUrl = work.url; | |
| 666 const icon = document.createElement("zen-icon"); | |
| 667 icon.setAttribute("name", TOOL_ICON_NAMES[work.url] || "settings"); | |
| 668 link.prepend(icon); | |
| 550 } | 669 } |
| 551 if (new URL(work.url, window.location.href).origin !== window.location.origin) { | 670 if (new URL(work.url, window.location.href).origin !== window.location.origin) { |
| 552 link.target = "_blank"; | 671 link.target = "_blank"; |
| 553 link.rel = "noreferrer"; | 672 link.rel = "noreferrer"; |
| 554 } | 673 } |
| 590 } | 709 } |
| 591 | 710 |
| 592 async loadBlogs() { | 711 async loadBlogs() { |
| 593 if (this._blogs) { | 712 if (this._blogs) { |
| 594 if (this.dataset.selection === "blog") { | 713 if (this.dataset.selection === "blog") { |
| 595 this.renderShowcase(this._blogs.slice(0, 4), true); | 714 this.renderShowcase([ |
| 715 ...this._blogs.slice(0, 4), | |
| 716 { | |
| 717 detail: "Full archive", | |
| 718 label: "All blogs", | |
| 719 modal: "blog", | |
| 720 url: "/blog", | |
| 721 }, | |
| 722 ], true); | |
| 596 } | 723 } |
| 597 return true; | 724 return true; |
| 598 } | 725 } |
| 599 if (this._blogsLoading) return this._blogsLoading; | 726 if (this._blogsLoading) return this._blogsLoading; |
| 600 const list = this.querySelector("[data-blog-list]"); | 727 const list = this.querySelector("[data-blog-list]"); |
| 620 url: anchor.getAttribute("href"), | 747 url: anchor.getAttribute("href"), |
| 621 })); | 748 })); |
| 622 if (!this._blogs.length) throw new Error("No blog entries were found"); | 749 if (!this._blogs.length) throw new Error("No blog entries were found"); |
| 623 this.renderBlogList(); | 750 this.renderBlogList(); |
| 624 if (this.dataset.selection === "blog") { | 751 if (this.dataset.selection === "blog") { |
| 625 this.renderShowcase(this._blogs.slice(0, 4), true); | 752 this.renderShowcase([ |
| 753 ...this._blogs.slice(0, 4), | |
| 754 { | |
| 755 detail: "Full archive", | |
| 756 label: "All blogs", | |
| 757 modal: "blog", | |
| 758 url: "/blog", | |
| 759 }, | |
| 760 ], true); | |
| 626 } | 761 } |
| 627 return true; | 762 return true; |
| 628 })().catch(error => { | 763 })().catch(error => { |
| 629 list.replaceChildren(); | 764 list.replaceChildren(); |
| 630 const item = document.createElement("li"); | 765 const item = document.createElement("li"); |
| 707 if (request !== this._blogRequest) return; | 842 if (request !== this._blogRequest) return; |
| 708 status.hidden = false; | 843 status.hidden = false; |
| 709 status.textContent = `Unable to load blog: ${error.message}`; | 844 status.textContent = `Unable to load blog: ${error.message}`; |
| 710 } | 845 } |
| 711 } | 846 } |
| 847 | |
| 848 async loadTools() { | |
| 849 if (this._tools) { | |
| 850 if (this.dataset.selection === "tools") { | |
| 851 this.renderShowcase(this._tools, false, "tool"); | |
| 852 } | |
| 853 return true; | |
| 854 } | |
| 855 if (this._toolsLoading) return this._toolsLoading; | |
| 856 this._toolsLoading = (async () => { | |
| 857 const response = await fetch("/tools", { | |
| 858 headers: { Accept: "text/html" }, | |
| 859 }); | |
| 860 if (!response.ok) { | |
| 861 throw new Error(`Tools request failed (${response.status})`); | |
| 862 } | |
| 863 const documentCopy = new DOMParser().parseFromString( | |
| 864 await response.text(), | |
| 865 "text/html", | |
| 866 ); | |
| 867 this._tools = [...documentCopy.querySelectorAll("main li a[href]")].map( | |
| 868 anchor => ({ | |
| 869 detail: anchor.getAttribute("href").startsWith("/notes") | |
| 870 ? "Writing" | |
| 871 : "Utility", | |
| 872 label: anchor.textContent.trim(), | |
| 873 url: anchor.getAttribute("href"), | |
| 874 }), | |
| 875 ); | |
| 876 if (!this._tools.length) throw new Error("No tools were found"); | |
| 877 if (this.dataset.selection === "tools") { | |
| 878 this.renderShowcase(this._tools, false, "tool"); | |
| 879 } | |
| 880 return true; | |
| 881 })().catch(error => { | |
| 882 const status = this.querySelector("[data-tool-status]"); | |
| 883 status.hidden = false; | |
| 884 status.textContent = `Unable to load tools: ${error.message}`; | |
| 885 return false; | |
| 886 }).finally(() => { | |
| 887 this._toolsLoading = null; | |
| 888 }); | |
| 889 return this._toolsLoading; | |
| 890 } | |
| 891 | |
| 892 async openToolWorkspace() { | |
| 893 if (!await this.loadTools() || this.dataset.selection !== "tools") return; | |
| 894 await this.loadToolDetail(this._activeToolUrl || this._tools[0].url); | |
| 895 } | |
| 896 | |
| 897 async loadToolDetail(url) { | |
| 898 const request = (this._toolRequest || 0) + 1; | |
| 899 this._toolRequest = request; | |
| 900 if (!await this.loadTools()) return; | |
| 901 if (request !== this._toolRequest || this.dataset.selection !== "tools") { | |
| 902 return; | |
| 903 } | |
| 904 const tool = this._tools.find(item => item.url === url); | |
| 905 const status = this.querySelector("[data-tool-status]"); | |
| 906 const content = this.querySelector("[data-tool-content]"); | |
| 907 if (!tool) { | |
| 908 status.hidden = false; | |
| 909 status.textContent = "Unable to load tool: unknown entry."; | |
| 910 return; | |
| 911 } | |
| 912 this._activeToolUrl = tool.url; | |
| 913 status.hidden = false; | |
| 914 status.textContent = `Loading ${tool.label}...`; | |
| 915 this.cleanupTool(); | |
| 916 content.replaceChildren(); | |
| 917 this.querySelector("[data-dialog-title]").textContent = tool.label; | |
| 918 const fullPage = this.querySelector("[data-preview-link]"); | |
| 919 fullPage.href = tool.url; | |
| 920 fullPage.setAttribute("aria-label", `Open ${tool.label}`); | |
| 921 if (tool.url === "/tools/markdown_to_html") { | |
| 922 await this.renderMarkdownTool(content, status, request); | |
| 923 return; | |
| 924 } | |
| 925 if (tool.url === "/tools/file_converter") { | |
| 926 this.renderFileConverterTool(content, status); | |
| 927 return; | |
| 928 } | |
| 929 if (tool.url === "/tools/hls_player") { | |
| 930 await this.renderHlsTool(content, status, request); | |
| 931 return; | |
| 932 } | |
| 933 if (tool.url === "/tools/latex_editor") { | |
| 934 this.renderLatexTool(content, status); | |
| 935 return; | |
| 936 } | |
| 937 if (tool.url === "/notes") { | |
| 938 const heading = document.createElement("h1"); | |
| 939 const description = document.createElement("p"); | |
| 940 const privacy = document.createElement("p"); | |
| 941 heading.textContent = "Personal Notes"; | |
| 942 description.textContent = | |
| 943 "A private browser-based writing workspace with rich-text editing and server-backed persistence."; | |
| 944 privacy.textContent = | |
| 945 "The interactive editor and authentication flow are available on the full Notes page."; | |
| 946 content.replaceChildren( | |
| 947 this.createReadonlyToolNote(), | |
| 948 heading, | |
| 949 description, | |
| 950 privacy, | |
| 951 ); | |
| 952 status.hidden = true; | |
| 953 return; | |
| 954 } | |
| 955 try { | |
| 956 const response = await fetch(tool.url, { | |
| 957 headers: { Accept: "text/html" }, | |
| 958 }); | |
| 959 if (!response.ok) { | |
| 960 throw new Error(`Tool request failed (${response.status})`); | |
| 961 } | |
| 962 const documentCopy = new DOMParser().parseFromString( | |
| 963 await response.text(), | |
| 964 "text/html", | |
| 965 ); | |
| 966 const detail = documentCopy.querySelector("main, .container") || | |
| 967 documentCopy.body; | |
| 968 if (!detail) throw new Error("Tool content is unavailable"); | |
| 969 sanitizeToolDetail(detail); | |
| 970 if (request !== this._toolRequest || this.dataset.selection !== "tools") { | |
| 971 return; | |
| 972 } | |
| 973 content.replaceChildren( | |
| 974 this.createReadonlyToolNote(), | |
| 975 ...detail.childNodes, | |
| 976 ); | |
| 977 status.hidden = true; | |
| 978 } catch (error) { | |
| 979 if (request !== this._toolRequest) return; | |
| 980 status.hidden = false; | |
| 981 status.textContent = `Unable to load tool: ${error.message}`; | |
| 982 } | |
| 983 } | |
| 984 | |
| 985 async renderMarkdownTool(content, status, request) { | |
| 986 content.innerHTML = ` | |
| 987 <section class="jrpg-functional-tool" data-functional-tool="markdown"> | |
| 988 <div class="jrpg-tool-panes"> | |
| 989 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 990 <h2><zen-icon name="code"></zen-icon> Markdown source</h2> | |
| 991 <zen-field appearance="plain" size="md"> | |
| 992 <label for="jrpgMarkdownSource">Markdown</label> | |
| 993 <textarea id="jrpgMarkdownSource" data-markdown-source></textarea> | |
| 994 </zen-field> | |
| 995 </section> | |
| 996 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 997 <h2><zen-icon name="check"></zen-icon> Converted HTML</h2> | |
| 998 <div class="jrpg-markdown-output" data-markdown-output></div> | |
| 999 </section> | |
| 1000 </div> | |
| 1001 </section> | |
| 1002 `; | |
| 1003 const source = content.querySelector("[data-markdown-source]"); | |
| 1004 const output = content.querySelector("[data-markdown-output]"); | |
| 1005 source.value = [ | |
| 1006 "# JRPG Markdown", | |
| 1007 "", | |
| 1008 "Edit this **Markdown** to update the converted panel.", | |
| 1009 "", | |
| 1010 "- Bazel-built WASM", | |
| 1011 "- Sanitized HTML output", | |
| 1012 ].join("\n"); | |
| 1013 status.hidden = false; | |
| 1014 status.textContent = "Loading the Markdown converter..."; | |
| 1015 try { | |
| 1016 this._markdownModule ||= import("/markdown_to_html_bin.js") | |
| 1017 .then(module => module.default()); | |
| 1018 const module = await this._markdownModule; | |
| 1019 if (request !== this._toolRequest || !source.isConnected) return; | |
| 1020 const convertMarkdown = module.cwrap( | |
| 1021 "markdown_to_html", | |
| 1022 "number", | |
| 1023 ["string"], | |
| 1024 ); | |
| 1025 const freeMarkdown = module.cwrap("markdown_free", null, ["number"]); | |
| 1026 const convert = () => { | |
| 1027 const pointer = convertMarkdown(source.value); | |
| 1028 const html = module.UTF8ToString(pointer); | |
| 1029 freeMarkdown(pointer); | |
| 1030 const documentCopy = new DOMParser().parseFromString(html, "text/html"); | |
| 1031 sanitizeFetchedMain(documentCopy.body); | |
| 1032 output.replaceChildren(...documentCopy.body.childNodes); | |
| 1033 }; | |
| 1034 source.addEventListener("input", convert); | |
| 1035 convert(); | |
| 1036 status.hidden = true; | |
| 1037 } catch (error) { | |
| 1038 this._markdownModule = null; | |
| 1039 if (request !== this._toolRequest) return; | |
| 1040 status.hidden = false; | |
| 1041 status.textContent = `Unable to start Markdown: ${error.message}`; | |
| 1042 } | |
| 1043 } | |
| 1044 | |
| 1045 renderFileConverterTool(content, status) { | |
| 1046 content.innerHTML = ` | |
| 1047 <section class="jrpg-functional-tool" data-functional-tool="converter"> | |
| 1048 <p class="jrpg-tool-status" data-converter-status> | |
| 1049 Choose a file in either panel. | |
| 1050 </p> | |
| 1051 <div class="jrpg-tool-panes"> | |
| 1052 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 1053 <h2><zen-icon name="file"></zen-icon> Image to WebP</h2> | |
| 1054 <zen-field appearance="plain" size="md"> | |
| 1055 <label for="jrpgImageInput">Image file</label> | |
| 1056 <input id="jrpgImageInput" data-converter-input="image" type="file" accept="image/*"> | |
| 1057 </zen-field> | |
| 1058 <zen-button appearance="plain" size="md"> | |
| 1059 <button type="button" data-convert-kind="image"> | |
| 1060 Convert image <zen-icon name="arrow-right"></zen-icon> | |
| 1061 </button> | |
| 1062 </zen-button> | |
| 1063 <a data-converter-download="image" hidden>Download WebP</a> | |
| 1064 </section> | |
| 1065 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 1066 <h2><zen-icon name="play"></zen-icon> Video to MP4</h2> | |
| 1067 <zen-field appearance="plain" size="md"> | |
| 1068 <label for="jrpgVideoInput">Video file</label> | |
| 1069 <input id="jrpgVideoInput" data-converter-input="video" type="file" accept="video/*"> | |
| 1070 </zen-field> | |
| 1071 <zen-button appearance="plain" size="md"> | |
| 1072 <button type="button" data-convert-kind="video"> | |
| 1073 Convert video <zen-icon name="arrow-right"></zen-icon> | |
| 1074 </button> | |
| 1075 </zen-button> | |
| 1076 <a data-converter-download="video" hidden>Download MP4</a> | |
| 1077 </section> | |
| 1078 </div> | |
| 1079 </section> | |
| 1080 `; | |
| 1081 const toolStatus = content.querySelector("[data-converter-status]"); | |
| 1082 let disposed = false; | |
| 1083 const downloads = new Set(); | |
| 1084 const discardDownload = url => { | |
| 1085 if (!url) return; | |
| 1086 downloads.delete(url); | |
| 1087 void fetch(url, { | |
| 1088 method: "DELETE", | |
| 1089 keepalive: true, | |
| 1090 }).then(response => { | |
| 1091 if (!response.ok && response.status !== 404) { | |
| 1092 console.warn(`Unable to discard converted file (${response.status})`); | |
| 1093 } | |
| 1094 }).catch(error => { | |
| 1095 console.warn(`Unable to discard converted file: ${error.message}`); | |
| 1096 }); | |
| 1097 }; | |
| 1098 const convert = async kind => { | |
| 1099 const input = content.querySelector(`[data-converter-input="${kind}"]`); | |
| 1100 const button = content.querySelector(`[data-convert-kind="${kind}"]`); | |
| 1101 const download = content.querySelector( | |
| 1102 `[data-converter-download="${kind}"]`, | |
| 1103 ); | |
| 1104 const file = input.files?.[0]; | |
| 1105 if (!file) { | |
| 1106 toolStatus.textContent = `Choose a ${kind} file first.`; | |
| 1107 toolStatus.dataset.state = "error"; | |
| 1108 return; | |
| 1109 } | |
| 1110 button.disabled = true; | |
| 1111 discardDownload(download.dataset.cleanupUrl); | |
| 1112 delete download.dataset.cleanupUrl; | |
| 1113 download.removeAttribute("href"); | |
| 1114 download.hidden = true; | |
| 1115 toolStatus.textContent = `Converting ${file.name}...`; | |
| 1116 toolStatus.dataset.state = "working"; | |
| 1117 try { | |
| 1118 const endpoint = kind === "image" | |
| 1119 ? "/api/convert/image-to-webp" | |
| 1120 : "/api/convert/video-to-mp4"; | |
| 1121 const response = await fetch(endpoint, { | |
| 1122 method: "POST", | |
| 1123 body: file, | |
| 1124 headers: { "Content-Type": file.type || "application/octet-stream" }, | |
| 1125 }); | |
| 1126 if (!response.ok) { | |
| 1127 throw new Error(await response.text() || `Conversion failed (${response.status})`); | |
| 1128 } | |
| 1129 const result = await response.json(); | |
| 1130 if (disposed || !download.isConnected) { | |
| 1131 discardDownload(result.download_url); | |
| 1132 return; | |
| 1133 } | |
| 1134 download.href = result.download_url; | |
| 1135 download.dataset.cleanupUrl = result.download_url; | |
| 1136 downloads.add(result.download_url); | |
| 1137 download.download = file.name.replace(/\.[^/.]+$/, "") + | |
| 1138 (kind === "image" ? ".webp" : ".mp4"); | |
| 1139 download.hidden = false; | |
| 1140 toolStatus.textContent = "Conversion complete."; | |
| 1141 toolStatus.dataset.state = "ready"; | |
| 1142 } catch (error) { | |
| 1143 if (!disposed) { | |
| 1144 toolStatus.textContent = `Conversion failed: ${error.message}`; | |
| 1145 toolStatus.dataset.state = "error"; | |
| 1146 } | |
| 1147 } finally { | |
| 1148 if (button.isConnected) button.disabled = false; | |
| 1149 } | |
| 1150 }; | |
| 1151 for (const button of content.querySelectorAll("[data-convert-kind]")) { | |
| 1152 button.addEventListener("click", () => { | |
| 1153 void convert(button.dataset.convertKind); | |
| 1154 }); | |
| 1155 } | |
| 1156 for (const download of content.querySelectorAll("[data-converter-download]")) { | |
| 1157 download.addEventListener("click", () => { | |
| 1158 downloads.delete(download.dataset.cleanupUrl); | |
| 1159 delete download.dataset.cleanupUrl; | |
| 1160 }); | |
| 1161 } | |
| 1162 this._toolCleanup = () => { | |
| 1163 disposed = true; | |
| 1164 for (const download of [...downloads]) discardDownload(download); | |
| 1165 }; | |
| 1166 status.hidden = true; | |
| 1167 } | |
| 1168 | |
| 1169 async renderHlsTool(content, status, request) { | |
| 1170 content.innerHTML = ` | |
| 1171 <section class="jrpg-functional-tool" data-functional-tool="hls"> | |
| 1172 <div class="jrpg-tool-panes"> | |
| 1173 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 1174 <h2><zen-icon name="file"></zen-icon> HLS source</h2> | |
| 1175 <zen-field appearance="plain" size="md"> | |
| 1176 <label for="jrpgHlsUrl">Playlist URL</label> | |
| 1177 <input | |
| 1178 id="jrpgHlsUrl" | |
| 1179 data-hls-url | |
| 1180 type="url" | |
| 1181 value="/public/hls-sample/h264-ts-stream.m3u8" | |
| 1182 > | |
| 1183 </zen-field> | |
| 1184 <div class="jrpg-tool-actions"> | |
| 1185 <zen-button appearance="plain" size="md"> | |
| 1186 <button type="button" data-hls-load> | |
| 1187 Load URL <zen-icon name="arrow-right"></zen-icon> | |
| 1188 </button> | |
| 1189 </zen-button> | |
| 1190 <zen-button appearance="plain" size="md"> | |
| 1191 <button type="button" data-hls-sample> | |
| 1192 Load sample <zen-icon name="play"></zen-icon> | |
| 1193 </button> | |
| 1194 </zen-button> | |
| 1195 </div> | |
| 1196 <p class="jrpg-tool-status" data-hls-status>Loading player engine...</p> | |
| 1197 </section> | |
| 1198 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 1199 <h2><zen-icon name="play"></zen-icon> Playback</h2> | |
| 1200 <video data-hls-video controls playsinline preload="metadata"></video> | |
| 1201 <dl class="jrpg-tool-details" data-hls-details hidden> | |
| 1202 <div><dt>Playback</dt><dd data-detail="mode">-</dd></div> | |
| 1203 <div><dt>Segments</dt><dd data-detail="segments">-</dd></div> | |
| 1204 <div><dt>Duration</dt><dd data-detail="duration">-</dd></div> | |
| 1205 <div><dt>Codec</dt><dd data-detail="codecs">-</dd></div> | |
| 1206 </dl> | |
| 1207 </section> | |
| 1208 </div> | |
| 1209 </section> | |
| 1210 `; | |
| 1211 try { | |
| 1212 await loadScript("/public/hls.min.js"); | |
| 1213 await loadScript("/tools/hls_player/hls-player.js"); | |
| 1214 if (request !== this._toolRequest || !content.isConnected) return; | |
| 1215 const video = content.querySelector("[data-hls-video]"); | |
| 1216 const playerStatus = content.querySelector("[data-hls-status]"); | |
| 1217 const details = content.querySelector("[data-hls-details]"); | |
| 1218 const url = content.querySelector("[data-hls-url]"); | |
| 1219 const player = new window.HlsPlayerModule.HlsPlayer(video, { | |
| 1220 statusElement: playerStatus, | |
| 1221 detailsElement: details, | |
| 1222 }); | |
| 1223 const load = () => { | |
| 1224 void player.load(url.value.trim()).catch(() => {}); | |
| 1225 }; | |
| 1226 content.querySelector("[data-hls-load]").addEventListener("click", load); | |
| 1227 content.querySelector("[data-hls-sample]").addEventListener("click", () => { | |
| 1228 url.value = "/public/hls-sample/h264-ts-stream.m3u8"; | |
| 1229 load(); | |
| 1230 }); | |
| 1231 playerStatus.textContent = "Ready to load a playlist."; | |
| 1232 playerStatus.dataset.state = "ready"; | |
| 1233 this._toolCleanup = () => player.destroy(); | |
| 1234 status.hidden = true; | |
| 1235 } catch (error) { | |
| 1236 if (request !== this._toolRequest) return; | |
| 1237 status.hidden = false; | |
| 1238 status.textContent = `Unable to start HLS: ${error.message}`; | |
| 1239 } | |
| 1240 } | |
| 1241 | |
| 1242 renderLatexTool(content, status) { | |
| 1243 content.innerHTML = ` | |
| 1244 <section class="jrpg-functional-tool" data-functional-tool="latex"> | |
| 1245 <p class="jrpg-tool-status" data-latex-status> | |
| 1246 Edit the source, then compile the PDF. | |
| 1247 </p> | |
| 1248 <div class="jrpg-tool-panes"> | |
| 1249 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 1250 <div class="jrpg-tool-pane-heading"> | |
| 1251 <h2><zen-icon name="code"></zen-icon> LaTeX source</h2> | |
| 1252 <span data-latex-size></span> | |
| 1253 </div> | |
| 1254 <zen-field appearance="plain" size="md"> | |
| 1255 <label for="jrpgLatexSource">document.tex</label> | |
| 1256 <textarea id="jrpgLatexSource" data-latex-source></textarea> | |
| 1257 </zen-field> | |
| 1258 <div class="jrpg-tool-actions"> | |
| 1259 <zen-button appearance="plain" size="md"> | |
| 1260 <button type="button" data-latex-compile> | |
| 1261 Compile PDF <zen-icon name="file"></zen-icon> | |
| 1262 </button> | |
| 1263 </zen-button> | |
| 1264 <zen-button appearance="plain" size="md" data-latex-download hidden> | |
| 1265 <a download="document.pdf"> | |
| 1266 Download PDF <zen-icon name="download"></zen-icon> | |
| 1267 </a> | |
| 1268 </zen-button> | |
| 1269 </div> | |
| 1270 </section> | |
| 1271 <section class="jrpg-tool-pane jrpg-tool-window"> | |
| 1272 <h2><zen-icon name="file"></zen-icon> PDF preview</h2> | |
| 1273 <iframe data-latex-preview title="Compiled LaTeX PDF preview"></iframe> | |
| 1274 <pre data-latex-diagnostics hidden></pre> | |
| 1275 </section> | |
| 1276 </div> | |
| 1277 </section> | |
| 1278 `; | |
| 1279 const source = content.querySelector("[data-latex-source]"); | |
| 1280 const compileButton = content.querySelector("[data-latex-compile]"); | |
| 1281 const toolStatus = content.querySelector("[data-latex-status]"); | |
| 1282 const size = content.querySelector("[data-latex-size]"); | |
| 1283 const preview = content.querySelector("[data-latex-preview]"); | |
| 1284 const diagnostics = content.querySelector("[data-latex-diagnostics]"); | |
| 1285 const downloadOwner = content.querySelector("[data-latex-download]"); | |
| 1286 const download = downloadOwner.querySelector("a"); | |
| 1287 source.value = String.raw`\documentclass[11pt]{article} | |
| 1288 \usepackage[margin=1in]{geometry} | |
| 1289 \title{JRPG LaTeX} | |
| 1290 \author{MrJuneJune} | |
| 1291 \begin{document} | |
| 1292 \maketitle | |
| 1293 This PDF was compiled from the cyberpunk tool modal. | |
| 1294 \end{document}`; | |
| 1295 let controller = null; | |
| 1296 let pdfUrl = null; | |
| 1297 let compileGeneration = 0; | |
| 1298 const clearPdf = () => { | |
| 1299 if (pdfUrl) URL.revokeObjectURL(pdfUrl); | |
| 1300 pdfUrl = null; | |
| 1301 preview.src = "about:blank"; | |
| 1302 preview.hidden = false; | |
| 1303 diagnostics.hidden = true; | |
| 1304 download.removeAttribute("href"); | |
| 1305 downloadOwner.hidden = true; | |
| 1306 }; | |
| 1307 const updateSize = () => { | |
| 1308 const bytes = new TextEncoder().encode(source.value).byteLength; | |
| 1309 size.textContent = `${bytes.toLocaleString()} / 65,536 bytes`; | |
| 1310 return bytes; | |
| 1311 }; | |
| 1312 const compile = async () => { | |
| 1313 const generation = ++compileGeneration; | |
| 1314 clearPdf(); | |
| 1315 const bytes = updateSize(); | |
| 1316 if (!source.value.trim() || bytes > 64 * 1024) { | |
| 1317 toolStatus.textContent = bytes > 64 * 1024 | |
| 1318 ? "Source exceeds 64 KiB." | |
| 1319 : "Write LaTeX before compiling."; | |
| 1320 toolStatus.dataset.state = "error"; | |
| 1321 return; | |
| 1322 } | |
| 1323 controller?.abort(); | |
| 1324 const requestController = new AbortController(); | |
| 1325 controller = requestController; | |
| 1326 compileButton.disabled = true; | |
| 1327 toolStatus.textContent = "Compiling on the server..."; | |
| 1328 toolStatus.dataset.state = "working"; | |
| 1329 try { | |
| 1330 const response = await fetch("/api/latex/render", { | |
| 1331 method: "POST", | |
| 1332 headers: { "Content-Type": "text/plain; charset=utf-8" }, | |
| 1333 body: source.value, | |
| 1334 cache: "no-store", | |
| 1335 signal: requestController.signal, | |
| 1336 }); | |
| 1337 if (generation !== compileGeneration) return; | |
| 1338 if (!response.ok) { | |
| 1339 throw new Error(await response.text() || `Compilation failed (${response.status})`); | |
| 1340 } | |
| 1341 const blob = await response.blob(); | |
| 1342 if (generation !== compileGeneration) return; | |
| 1343 if (blob.type !== "application/pdf" || blob.size < 5) { | |
| 1344 throw new Error("The server returned an invalid PDF."); | |
| 1345 } | |
| 1346 if (pdfUrl) URL.revokeObjectURL(pdfUrl); | |
| 1347 pdfUrl = URL.createObjectURL(blob); | |
| 1348 preview.src = pdfUrl; | |
| 1349 preview.hidden = false; | |
| 1350 diagnostics.hidden = true; | |
| 1351 download.href = pdfUrl; | |
| 1352 downloadOwner.hidden = false; | |
| 1353 toolStatus.textContent = `PDF ready (${Math.ceil(blob.size / 1024)} KiB).`; | |
| 1354 toolStatus.dataset.state = "ready"; | |
| 1355 } catch (error) { | |
| 1356 if (error.name !== "AbortError" && generation === compileGeneration) { | |
| 1357 diagnostics.textContent = error.message; | |
| 1358 diagnostics.hidden = false; | |
| 1359 preview.hidden = true; | |
| 1360 toolStatus.textContent = "Compilation failed."; | |
| 1361 toolStatus.dataset.state = "error"; | |
| 1362 } | |
| 1363 } finally { | |
| 1364 if (controller === requestController) { | |
| 1365 controller = null; | |
| 1366 compileButton.disabled = false; | |
| 1367 } | |
| 1368 } | |
| 1369 }; | |
| 1370 source.addEventListener("input", () => { | |
| 1371 compileGeneration++; | |
| 1372 controller?.abort(); | |
| 1373 controller = null; | |
| 1374 compileButton.disabled = false; | |
| 1375 clearPdf(); | |
| 1376 updateSize(); | |
| 1377 toolStatus.textContent = "Changes ready to compile."; | |
| 1378 toolStatus.dataset.state = "working"; | |
| 1379 }); | |
| 1380 compileButton.addEventListener("click", () => void compile()); | |
| 1381 updateSize(); | |
| 1382 this._toolCleanup = () => { | |
| 1383 compileGeneration++; | |
| 1384 controller?.abort(); | |
| 1385 clearPdf(); | |
| 1386 }; | |
| 1387 status.hidden = true; | |
| 1388 } | |
| 1389 | |
| 1390 cleanupTool() { | |
| 1391 this._toolCleanup?.(); | |
| 1392 this._toolCleanup = null; | |
| 1393 } | |
| 1394 | |
| 1395 createReadonlyToolNote() { | |
| 1396 const introduction = document.createElement("p"); | |
| 1397 introduction.className = "jrpg-tool-readonly-note"; | |
| 1398 introduction.textContent = | |
| 1399 "Read-only preview. Open the full page to use this tool."; | |
| 1400 return introduction; | |
| 1401 } | |
| 712 } | 1402 } |
| 713 | 1403 |
| 714 class MjjJrpgShell extends HTMLElement {} | 1404 class MjjJrpgShell extends HTMLElement {} |
| 715 | 1405 |
| 716 for (const [name, constructor] of Object.entries({ | 1406 for (const [name, constructor] of Object.entries({ |
| 790 const shell = document.querySelector("mjj-jrpg-shell"); | 1480 const shell = document.querySelector("mjj-jrpg-shell"); |
| 791 const character = shell?.querySelector("mjj-jrpg-character"); | 1481 const character = shell?.querySelector("mjj-jrpg-character"); |
| 792 const chat = shell?.querySelector("mjj-jrpg-chat"); | 1482 const chat = shell?.querySelector("mjj-jrpg-chat"); |
| 793 const composer = shell?.querySelector("mjj-jrpg-composer"); | 1483 const composer = shell?.querySelector("mjj-jrpg-composer"); |
| 794 const preview = shell?.querySelector("mjj-jrpg-preview"); | 1484 const preview = shell?.querySelector("mjj-jrpg-preview"); |
| 1485 const frameNetwork = shell?.querySelector("[data-frame-network]"); | |
| 1486 const frameUptime = shell?.querySelector("[data-frame-uptime]"); | |
| 1487 const minimizeButton = shell?.querySelector("[data-frame-minimize]"); | |
| 1488 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); | |
| 795 let characterTimer = 0; | 1489 let characterTimer = 0; |
| 796 let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); | 1490 let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); |
| 797 let activeController = null; | 1491 let activeController = null; |
| 798 composer?.setDisabled(true); | 1492 composer?.setDisabled(true); |
| 1493 const setFrameStatus = (label, state, title = "") => { | |
| 1494 if (frameNetwork) { | |
| 1495 frameNetwork.textContent = label; | |
| 1496 frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); | |
| 1497 frameNetwork.dataset.state = state; | |
| 1498 frameNetwork.title = title; | |
| 1499 } | |
| 1500 }; | |
| 1501 setFrameStatus("ONLINE", "online"); | |
| 1502 const startedAt = Date.now(); | |
| 1503 const updateUptime = () => { | |
| 1504 const elapsed = Math.floor((Date.now() - startedAt) / 1000); | |
| 1505 const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); | |
| 1506 const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); | |
| 1507 const seconds = String(elapsed % 60).padStart(2, "0"); | |
| 1508 if (frameUptime) { | |
| 1509 frameUptime.textContent = `${hours}:${minutes}:${seconds}`; | |
| 1510 } | |
| 1511 }; | |
| 1512 updateUptime(); | |
| 1513 window.setInterval(updateUptime, 1000); | |
| 1514 | |
| 1515 minimizeButton?.addEventListener("click", () => { | |
| 1516 const minimized = shell.dataset.minimized !== "true"; | |
| 1517 shell.dataset.minimized = String(minimized); | |
| 1518 minimizeButton.setAttribute("aria-pressed", String(minimized)); | |
| 1519 minimizeButton.setAttribute( | |
| 1520 "aria-label", | |
| 1521 minimized ? "Restore interface" : "Minimize interface", | |
| 1522 ); | |
| 1523 }); | |
| 1524 fullscreenButton?.addEventListener("click", async () => { | |
| 1525 try { | |
| 1526 if (document.fullscreenElement) await document.exitFullscreen(); | |
| 1527 else await shell.requestFullscreen(); | |
| 1528 } catch (error) { | |
| 1529 setFrameStatus("DENIED", "offline", error.message); | |
| 1530 } | |
| 1531 }); | |
| 1532 document.addEventListener("fullscreenchange", () => { | |
| 1533 fullscreenButton?.setAttribute( | |
| 1534 "aria-pressed", | |
| 1535 String(Boolean(document.fullscreenElement)), | |
| 1536 ); | |
| 1537 }); | |
| 799 | 1538 |
| 800 if (conversationId) { | 1539 if (conversationId) { |
| 801 try { | 1540 try { |
| 802 const conversation = await requestJson( | 1541 const conversation = await requestJson( |
| 803 `/api/conversations/${encodeURIComponent(conversationId)}`, | 1542 `/api/conversations/${encodeURIComponent(conversationId)}`, |
| 909 if (activeController.signal.aborted) { | 1648 if (activeController.signal.aborted) { |
| 910 throw new DOMException("Quest cancelled", "AbortError"); | 1649 throw new DOMException("Quest cancelled", "AbortError"); |
| 911 } | 1650 } |
| 912 chat?.setMessageStreaming(assistantItem, false); | 1651 chat?.setMessageStreaming(assistantItem, false); |
| 913 character?.setAttribute("state", "default"); | 1652 character?.setAttribute("state", "default"); |
| 1653 setFrameStatus("ONLINE", "online"); | |
| 914 } catch (error) { | 1654 } catch (error) { |
| 915 const aborted = error instanceof DOMException && error.name === "AbortError"; | 1655 const aborted = error instanceof DOMException && error.name === "AbortError"; |
| 916 assistantText.cancel(); | 1656 assistantText.cancel(); |
| 917 chat?.setMessageStreaming(assistantItem, false); | 1657 chat?.setMessageStreaming(assistantItem, false); |
| 918 chat?.setMessageText( | 1658 chat?.setMessageText( |
| 919 assistantItem, | 1659 assistantItem, |
| 920 aborted ? "Quest cancelled." : `System error: ${error.message}`, | 1660 aborted ? "Quest cancelled." : `System error: ${error.message}`, |
| 921 ); | 1661 ); |
| 922 character?.setAttribute("state", "sad"); | 1662 character?.setAttribute("state", "sad"); |
| 1663 if (!aborted) setFrameStatus("OFFLINE", "offline"); | |
| 923 } finally { | 1664 } finally { |
| 924 composer?.setBusy(false); | 1665 composer?.setBusy(false); |
| 925 composer?.querySelector("textarea")?.focus(); | 1666 composer?.querySelector("textarea")?.focus(); |
| 926 activeController = null; | 1667 activeController = null; |
| 927 } | 1668 } |