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 }