Mercurial
diff 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 |
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Thu Aug 06 11:31:30 2026 -0700 @@ -18,10 +18,15 @@ copy: "Experience, projects, and the systems I have helped build.", kicker: "CHARACTER RECORD", title: "Resume", - type: "Profile", url: "/resume", works: [ { + detail: "Career dossier", + label: "Full resume", + modal: "resume", + url: "/resume", + }, + { detail: "Agentic execution", label: "Copilot Tasks", url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", @@ -57,7 +62,6 @@ copy: "Small, focused utilities for writing, media, and experimentation.", kicker: "ITEM INVENTORY", title: "Tools", - type: "Utilities", url: "/tools", works: [ { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, @@ -69,7 +73,6 @@ copy: "Notes from building systems, games, web tools, and curious prototypes.", kicker: "QUEST ARCHIVE", title: "Blogs", - type: "Writing", url: "/blog", works: [ { detail: "Archive", label: "All posts", url: "/blog" }, @@ -79,6 +82,31 @@ const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; const TYPING_INTERVAL_MS = 18; +const SCRIPT_LOADS = new Map(); +const TOOL_ICON_NAMES = Object.freeze({ + "/notes": "repository", + "/tools/file_converter": "retry", + "/tools/hls_player": "play", + "/tools/latex_editor": "file", + "/tools/markdown_to_html": "code", +}); + +function loadScript(source) { + if (SCRIPT_LOADS.has(source)) return SCRIPT_LOADS.get(source); + const loading = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = source; + script.addEventListener("load", resolve, { once: true }); + script.addEventListener("error", () => { + SCRIPT_LOADS.delete(source); + script.remove(); + reject(new Error(`Unable to load ${source}`)); + }, { once: true }); + document.head.append(script); + }); + SCRIPT_LOADS.set(source, loading); + return loading; +} class StreamingTextAnimator { constructor(render) { @@ -443,14 +471,20 @@ } for (const element of main.querySelectorAll("*")) { for (const attribute of [...element.attributes]) { - if (attribute.name.toLowerCase().startsWith("on")) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on") || name === "id" || name === "style") { element.removeAttribute(attribute.name); } } } for (const anchor of main.querySelectorAll("a")) { + const href = anchor.getAttribute("href"); + if (!href || anchor.getAttribute("aria-disabled") === "true") { + anchor.remove(); + continue; + } const target = new URL( - anchor.getAttribute("href") || "", + href, window.location.href, ); anchor.href = target.href; @@ -469,8 +503,37 @@ return main; } +function sanitizeToolDetail(main) { + sanitizeFetchedMain(main); + for (const textarea of main.querySelectorAll("textarea")) { + const sample = document.createElement("pre"); + sample.className = "jrpg-tool-source-sample"; + sample.textContent = textarea.value || textarea.textContent; + textarea.replaceWith(sample); + } + for (const inactive of main.querySelectorAll( + "button, canvas, dialog, input, select, video, [hidden]", + )) { + inactive.remove(); + } + for (const owner of main.querySelectorAll( + "zen-button, zen-checkbox, zen-field, zen-input, zen-textarea", + )) { + owner.replaceWith(...owner.childNodes); + } + for (const label of main.querySelectorAll("label[for]")) { + label.removeAttribute("for"); + } + return main; +} + class MjjJrpgPreview extends HTMLElement { connectedCallback() { + this._dialog = this.querySelector("dialog"); + this._onDialogClose = () => { + this._toolRequest = (this._toolRequest || 0) + 1; + this.cleanupTool(); + }; this._onClick = event => { const trigger = event.target.closest("[data-zen-trigger]"); if (trigger) { @@ -479,6 +542,10 @@ this.show("blog"); void this.loadBlogs(); } + if (this.dataset.selection === "tools") { + this.show("tools"); + void this.openToolWorkspace(); + } return; } const blogEntry = event.target.closest("[data-blog-entry]"); @@ -491,23 +558,49 @@ event.preventDefault(); this.querySelector("zen-dialog").open(); void this.loadBlogDetail(latestBlog.dataset.blogUrl); + return; + } + const blogArchive = event.target.closest("[data-blog-archive]"); + if (blogArchive) { + event.preventDefault(); + this.querySelector("zen-dialog").open(); + this.show("blog"); + void this.loadBlogs(); + return; + } + const resumeModal = event.target.closest("[data-resume-modal]"); + if (resumeModal) { + event.preventDefault(); + this.querySelector("zen-dialog").open(); + this.show("resume"); + void this.loadResume(); + return; + } + const latestTool = event.target.closest("[data-latest-tool]"); + if (latestTool && !event.metaKey && !event.ctrlKey && !event.shiftKey) { + event.preventDefault(); + this.querySelector("zen-dialog").open(); + void this.loadToolDetail(latestTool.dataset.toolUrl); } }; this.addEventListener("click", this._onClick); + this._dialog.addEventListener("close", this._onDialogClose); this.show(this.dataset.selection || "resume"); } disconnectedCallback() { this.removeEventListener("click", this._onClick); + this._dialog?.removeEventListener("close", this._onDialogClose); + this.cleanupTool(); } show(selection) { + this.cleanupTool(); const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; this.querySelector("[data-preview-kicker]").textContent = preview.kicker; this.querySelector("[data-preview-title]").textContent = preview.title; this.querySelector("[data-preview-copy]").textContent = preview.copy; - this.querySelector("[data-preview-type]").textContent = preview.type; this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; this.renderShowcase(preview.works); @@ -517,12 +610,18 @@ const resumeDossier = this.querySelector("[data-resume-dossier]"); const resumeDownload = this.querySelector("[data-resume-download]"); const blogBrowser = this.querySelector("[data-blog-browser]"); + const toolBrowser = this.querySelector("[data-tool-browser]"); const isResume = selection === "resume"; const isBlog = selection === "blog"; + const isTools = selection === "tools"; + this._dialog.dataset.detailMode = isTools ? "tools" : "content"; resumeDossier.hidden = !isResume; resumeDownload.hidden = !isResume; + this.querySelector("[data-dialog-actions]").hidden = !isResume; blogBrowser.hidden = !isBlog; - this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog; + toolBrowser.hidden = !isTools; + this.querySelector("[data-dialog-copy]").hidden = + isResume || isBlog || isTools; if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; if (isBlog) { const status = this.querySelector("[data-blog-status]"); @@ -534,9 +633,17 @@ } void this.loadBlogs(); } + if (!isTools) this._toolRequest = (this._toolRequest || 0) + 1; + if (isTools) { + const status = this.querySelector("[data-tool-status]"); + status.hidden = false; + status.textContent = "Select a tool to inspect."; + this.querySelector("[data-tool-content]").replaceChildren(); + void this.loadTools(); + } } - renderShowcase(works, latestBlogs = false) { + renderShowcase(works, latestBlogs = false, latestKind = null) { const showcase = this.querySelector("[data-work-showcase]"); showcase.replaceChildren(...works.map(work => { const item = document.createElement("li"); @@ -545,8 +652,20 @@ const detail = document.createElement("small"); link.href = work.url; if (latestBlogs) { - link.dataset.latestBlog = ""; - link.dataset.blogUrl = work.url; + if (work.modal === "blog") { + link.dataset.blogArchive = ""; + } else { + link.dataset.latestBlog = ""; + link.dataset.blogUrl = work.url; + } + } + if (work.modal === "resume") link.dataset.resumeModal = ""; + if (latestKind === "tool") { + link.dataset.latestTool = ""; + link.dataset.toolUrl = work.url; + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", TOOL_ICON_NAMES[work.url] || "settings"); + link.prepend(icon); } if (new URL(work.url, window.location.href).origin !== window.location.origin) { link.target = "_blank"; @@ -592,7 +711,15 @@ async loadBlogs() { if (this._blogs) { if (this.dataset.selection === "blog") { - this.renderShowcase(this._blogs.slice(0, 4), true); + this.renderShowcase([ + ...this._blogs.slice(0, 4), + { + detail: "Full archive", + label: "All blogs", + modal: "blog", + url: "/blog", + }, + ], true); } return true; } @@ -622,7 +749,15 @@ if (!this._blogs.length) throw new Error("No blog entries were found"); this.renderBlogList(); if (this.dataset.selection === "blog") { - this.renderShowcase(this._blogs.slice(0, 4), true); + this.renderShowcase([ + ...this._blogs.slice(0, 4), + { + detail: "Full archive", + label: "All blogs", + modal: "blog", + url: "/blog", + }, + ], true); } return true; })().catch(error => { @@ -709,6 +844,561 @@ status.textContent = `Unable to load blog: ${error.message}`; } } + + async loadTools() { + if (this._tools) { + if (this.dataset.selection === "tools") { + this.renderShowcase(this._tools, false, "tool"); + } + return true; + } + if (this._toolsLoading) return this._toolsLoading; + this._toolsLoading = (async () => { + const response = await fetch("/tools", { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Tools request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + this._tools = [...documentCopy.querySelectorAll("main li a[href]")].map( + anchor => ({ + detail: anchor.getAttribute("href").startsWith("/notes") + ? "Writing" + : "Utility", + label: anchor.textContent.trim(), + url: anchor.getAttribute("href"), + }), + ); + if (!this._tools.length) throw new Error("No tools were found"); + if (this.dataset.selection === "tools") { + this.renderShowcase(this._tools, false, "tool"); + } + return true; + })().catch(error => { + const status = this.querySelector("[data-tool-status]"); + status.hidden = false; + status.textContent = `Unable to load tools: ${error.message}`; + return false; + }).finally(() => { + this._toolsLoading = null; + }); + return this._toolsLoading; + } + + async openToolWorkspace() { + if (!await this.loadTools() || this.dataset.selection !== "tools") return; + await this.loadToolDetail(this._activeToolUrl || this._tools[0].url); + } + + async loadToolDetail(url) { + const request = (this._toolRequest || 0) + 1; + this._toolRequest = request; + if (!await this.loadTools()) return; + if (request !== this._toolRequest || this.dataset.selection !== "tools") { + return; + } + const tool = this._tools.find(item => item.url === url); + const status = this.querySelector("[data-tool-status]"); + const content = this.querySelector("[data-tool-content]"); + if (!tool) { + status.hidden = false; + status.textContent = "Unable to load tool: unknown entry."; + return; + } + this._activeToolUrl = tool.url; + status.hidden = false; + status.textContent = `Loading ${tool.label}...`; + this.cleanupTool(); + content.replaceChildren(); + this.querySelector("[data-dialog-title]").textContent = tool.label; + const fullPage = this.querySelector("[data-preview-link]"); + fullPage.href = tool.url; + fullPage.setAttribute("aria-label", `Open ${tool.label}`); + if (tool.url === "/tools/markdown_to_html") { + await this.renderMarkdownTool(content, status, request); + return; + } + if (tool.url === "/tools/file_converter") { + this.renderFileConverterTool(content, status); + return; + } + if (tool.url === "/tools/hls_player") { + await this.renderHlsTool(content, status, request); + return; + } + if (tool.url === "/tools/latex_editor") { + this.renderLatexTool(content, status); + return; + } + if (tool.url === "/notes") { + const heading = document.createElement("h1"); + const description = document.createElement("p"); + const privacy = document.createElement("p"); + heading.textContent = "Personal Notes"; + description.textContent = + "A private browser-based writing workspace with rich-text editing and server-backed persistence."; + privacy.textContent = + "The interactive editor and authentication flow are available on the full Notes page."; + content.replaceChildren( + this.createReadonlyToolNote(), + heading, + description, + privacy, + ); + status.hidden = true; + return; + } + try { + const response = await fetch(tool.url, { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Tool request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + const detail = documentCopy.querySelector("main, .container") || + documentCopy.body; + if (!detail) throw new Error("Tool content is unavailable"); + sanitizeToolDetail(detail); + if (request !== this._toolRequest || this.dataset.selection !== "tools") { + return; + } + content.replaceChildren( + this.createReadonlyToolNote(), + ...detail.childNodes, + ); + status.hidden = true; + } catch (error) { + if (request !== this._toolRequest) return; + status.hidden = false; + status.textContent = `Unable to load tool: ${error.message}`; + } + } + + async renderMarkdownTool(content, status, request) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="markdown"> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="code"></zen-icon> Markdown source</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgMarkdownSource">Markdown</label> + <textarea id="jrpgMarkdownSource" data-markdown-source></textarea> + </zen-field> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="check"></zen-icon> Converted HTML</h2> + <div class="jrpg-markdown-output" data-markdown-output></div> + </section> + </div> + </section> + `; + const source = content.querySelector("[data-markdown-source]"); + const output = content.querySelector("[data-markdown-output]"); + source.value = [ + "# JRPG Markdown", + "", + "Edit this **Markdown** to update the converted panel.", + "", + "- Bazel-built WASM", + "- Sanitized HTML output", + ].join("\n"); + status.hidden = false; + status.textContent = "Loading the Markdown converter..."; + try { + this._markdownModule ||= import("/markdown_to_html_bin.js") + .then(module => module.default()); + const module = await this._markdownModule; + if (request !== this._toolRequest || !source.isConnected) return; + const convertMarkdown = module.cwrap( + "markdown_to_html", + "number", + ["string"], + ); + const freeMarkdown = module.cwrap("markdown_free", null, ["number"]); + const convert = () => { + const pointer = convertMarkdown(source.value); + const html = module.UTF8ToString(pointer); + freeMarkdown(pointer); + const documentCopy = new DOMParser().parseFromString(html, "text/html"); + sanitizeFetchedMain(documentCopy.body); + output.replaceChildren(...documentCopy.body.childNodes); + }; + source.addEventListener("input", convert); + convert(); + status.hidden = true; + } catch (error) { + this._markdownModule = null; + if (request !== this._toolRequest) return; + status.hidden = false; + status.textContent = `Unable to start Markdown: ${error.message}`; + } + } + + renderFileConverterTool(content, status) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="converter"> + <p class="jrpg-tool-status" data-converter-status> + Choose a file in either panel. + </p> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="file"></zen-icon> Image to WebP</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgImageInput">Image file</label> + <input id="jrpgImageInput" data-converter-input="image" type="file" accept="image/*"> + </zen-field> + <zen-button appearance="plain" size="md"> + <button type="button" data-convert-kind="image"> + Convert image <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <a data-converter-download="image" hidden>Download WebP</a> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="play"></zen-icon> Video to MP4</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgVideoInput">Video file</label> + <input id="jrpgVideoInput" data-converter-input="video" type="file" accept="video/*"> + </zen-field> + <zen-button appearance="plain" size="md"> + <button type="button" data-convert-kind="video"> + Convert video <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <a data-converter-download="video" hidden>Download MP4</a> + </section> + </div> + </section> + `; + const toolStatus = content.querySelector("[data-converter-status]"); + let disposed = false; + const downloads = new Set(); + const discardDownload = url => { + if (!url) return; + downloads.delete(url); + void fetch(url, { + method: "DELETE", + keepalive: true, + }).then(response => { + if (!response.ok && response.status !== 404) { + console.warn(`Unable to discard converted file (${response.status})`); + } + }).catch(error => { + console.warn(`Unable to discard converted file: ${error.message}`); + }); + }; + const convert = async kind => { + const input = content.querySelector(`[data-converter-input="${kind}"]`); + const button = content.querySelector(`[data-convert-kind="${kind}"]`); + const download = content.querySelector( + `[data-converter-download="${kind}"]`, + ); + const file = input.files?.[0]; + if (!file) { + toolStatus.textContent = `Choose a ${kind} file first.`; + toolStatus.dataset.state = "error"; + return; + } + button.disabled = true; + discardDownload(download.dataset.cleanupUrl); + delete download.dataset.cleanupUrl; + download.removeAttribute("href"); + download.hidden = true; + toolStatus.textContent = `Converting ${file.name}...`; + toolStatus.dataset.state = "working"; + try { + const endpoint = kind === "image" + ? "/api/convert/image-to-webp" + : "/api/convert/video-to-mp4"; + const response = await fetch(endpoint, { + method: "POST", + body: file, + headers: { "Content-Type": file.type || "application/octet-stream" }, + }); + if (!response.ok) { + throw new Error(await response.text() || `Conversion failed (${response.status})`); + } + const result = await response.json(); + if (disposed || !download.isConnected) { + discardDownload(result.download_url); + return; + } + download.href = result.download_url; + download.dataset.cleanupUrl = result.download_url; + downloads.add(result.download_url); + download.download = file.name.replace(/\.[^/.]+$/, "") + + (kind === "image" ? ".webp" : ".mp4"); + download.hidden = false; + toolStatus.textContent = "Conversion complete."; + toolStatus.dataset.state = "ready"; + } catch (error) { + if (!disposed) { + toolStatus.textContent = `Conversion failed: ${error.message}`; + toolStatus.dataset.state = "error"; + } + } finally { + if (button.isConnected) button.disabled = false; + } + }; + for (const button of content.querySelectorAll("[data-convert-kind]")) { + button.addEventListener("click", () => { + void convert(button.dataset.convertKind); + }); + } + for (const download of content.querySelectorAll("[data-converter-download]")) { + download.addEventListener("click", () => { + downloads.delete(download.dataset.cleanupUrl); + delete download.dataset.cleanupUrl; + }); + } + this._toolCleanup = () => { + disposed = true; + for (const download of [...downloads]) discardDownload(download); + }; + status.hidden = true; + } + + async renderHlsTool(content, status, request) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="hls"> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="file"></zen-icon> HLS source</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgHlsUrl">Playlist URL</label> + <input + id="jrpgHlsUrl" + data-hls-url + type="url" + value="/public/hls-sample/h264-ts-stream.m3u8" + > + </zen-field> + <div class="jrpg-tool-actions"> + <zen-button appearance="plain" size="md"> + <button type="button" data-hls-load> + Load URL <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="md"> + <button type="button" data-hls-sample> + Load sample <zen-icon name="play"></zen-icon> + </button> + </zen-button> + </div> + <p class="jrpg-tool-status" data-hls-status>Loading player engine...</p> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="play"></zen-icon> Playback</h2> + <video data-hls-video controls playsinline preload="metadata"></video> + <dl class="jrpg-tool-details" data-hls-details hidden> + <div><dt>Playback</dt><dd data-detail="mode">-</dd></div> + <div><dt>Segments</dt><dd data-detail="segments">-</dd></div> + <div><dt>Duration</dt><dd data-detail="duration">-</dd></div> + <div><dt>Codec</dt><dd data-detail="codecs">-</dd></div> + </dl> + </section> + </div> + </section> + `; + try { + await loadScript("/public/hls.min.js"); + await loadScript("/tools/hls_player/hls-player.js"); + if (request !== this._toolRequest || !content.isConnected) return; + const video = content.querySelector("[data-hls-video]"); + const playerStatus = content.querySelector("[data-hls-status]"); + const details = content.querySelector("[data-hls-details]"); + const url = content.querySelector("[data-hls-url]"); + const player = new window.HlsPlayerModule.HlsPlayer(video, { + statusElement: playerStatus, + detailsElement: details, + }); + const load = () => { + void player.load(url.value.trim()).catch(() => {}); + }; + content.querySelector("[data-hls-load]").addEventListener("click", load); + content.querySelector("[data-hls-sample]").addEventListener("click", () => { + url.value = "/public/hls-sample/h264-ts-stream.m3u8"; + load(); + }); + playerStatus.textContent = "Ready to load a playlist."; + playerStatus.dataset.state = "ready"; + this._toolCleanup = () => player.destroy(); + status.hidden = true; + } catch (error) { + if (request !== this._toolRequest) return; + status.hidden = false; + status.textContent = `Unable to start HLS: ${error.message}`; + } + } + + renderLatexTool(content, status) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="latex"> + <p class="jrpg-tool-status" data-latex-status> + Edit the source, then compile the PDF. + </p> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <div class="jrpg-tool-pane-heading"> + <h2><zen-icon name="code"></zen-icon> LaTeX source</h2> + <span data-latex-size></span> + </div> + <zen-field appearance="plain" size="md"> + <label for="jrpgLatexSource">document.tex</label> + <textarea id="jrpgLatexSource" data-latex-source></textarea> + </zen-field> + <div class="jrpg-tool-actions"> + <zen-button appearance="plain" size="md"> + <button type="button" data-latex-compile> + Compile PDF <zen-icon name="file"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="md" data-latex-download hidden> + <a download="document.pdf"> + Download PDF <zen-icon name="download"></zen-icon> + </a> + </zen-button> + </div> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="file"></zen-icon> PDF preview</h2> + <iframe data-latex-preview title="Compiled LaTeX PDF preview"></iframe> + <pre data-latex-diagnostics hidden></pre> + </section> + </div> + </section> + `; + const source = content.querySelector("[data-latex-source]"); + const compileButton = content.querySelector("[data-latex-compile]"); + const toolStatus = content.querySelector("[data-latex-status]"); + const size = content.querySelector("[data-latex-size]"); + const preview = content.querySelector("[data-latex-preview]"); + const diagnostics = content.querySelector("[data-latex-diagnostics]"); + const downloadOwner = content.querySelector("[data-latex-download]"); + const download = downloadOwner.querySelector("a"); + source.value = String.raw`\documentclass[11pt]{article} +\usepackage[margin=1in]{geometry} +\title{JRPG LaTeX} +\author{MrJuneJune} +\begin{document} +\maketitle +This PDF was compiled from the cyberpunk tool modal. +\end{document}`; + let controller = null; + let pdfUrl = null; + let compileGeneration = 0; + const clearPdf = () => { + if (pdfUrl) URL.revokeObjectURL(pdfUrl); + pdfUrl = null; + preview.src = "about:blank"; + preview.hidden = false; + diagnostics.hidden = true; + download.removeAttribute("href"); + downloadOwner.hidden = true; + }; + const updateSize = () => { + const bytes = new TextEncoder().encode(source.value).byteLength; + size.textContent = `${bytes.toLocaleString()} / 65,536 bytes`; + return bytes; + }; + const compile = async () => { + const generation = ++compileGeneration; + clearPdf(); + const bytes = updateSize(); + if (!source.value.trim() || bytes > 64 * 1024) { + toolStatus.textContent = bytes > 64 * 1024 + ? "Source exceeds 64 KiB." + : "Write LaTeX before compiling."; + toolStatus.dataset.state = "error"; + return; + } + controller?.abort(); + const requestController = new AbortController(); + controller = requestController; + compileButton.disabled = true; + toolStatus.textContent = "Compiling on the server..."; + toolStatus.dataset.state = "working"; + try { + const response = await fetch("/api/latex/render", { + method: "POST", + headers: { "Content-Type": "text/plain; charset=utf-8" }, + body: source.value, + cache: "no-store", + signal: requestController.signal, + }); + if (generation !== compileGeneration) return; + if (!response.ok) { + throw new Error(await response.text() || `Compilation failed (${response.status})`); + } + const blob = await response.blob(); + if (generation !== compileGeneration) return; + if (blob.type !== "application/pdf" || blob.size < 5) { + throw new Error("The server returned an invalid PDF."); + } + if (pdfUrl) URL.revokeObjectURL(pdfUrl); + pdfUrl = URL.createObjectURL(blob); + preview.src = pdfUrl; + preview.hidden = false; + diagnostics.hidden = true; + download.href = pdfUrl; + downloadOwner.hidden = false; + toolStatus.textContent = `PDF ready (${Math.ceil(blob.size / 1024)} KiB).`; + toolStatus.dataset.state = "ready"; + } catch (error) { + if (error.name !== "AbortError" && generation === compileGeneration) { + diagnostics.textContent = error.message; + diagnostics.hidden = false; + preview.hidden = true; + toolStatus.textContent = "Compilation failed."; + toolStatus.dataset.state = "error"; + } + } finally { + if (controller === requestController) { + controller = null; + compileButton.disabled = false; + } + } + }; + source.addEventListener("input", () => { + compileGeneration++; + controller?.abort(); + controller = null; + compileButton.disabled = false; + clearPdf(); + updateSize(); + toolStatus.textContent = "Changes ready to compile."; + toolStatus.dataset.state = "working"; + }); + compileButton.addEventListener("click", () => void compile()); + updateSize(); + this._toolCleanup = () => { + compileGeneration++; + controller?.abort(); + clearPdf(); + }; + status.hidden = true; + } + + cleanupTool() { + this._toolCleanup?.(); + this._toolCleanup = null; + } + + createReadonlyToolNote() { + const introduction = document.createElement("p"); + introduction.className = "jrpg-tool-readonly-note"; + introduction.textContent = + "Read-only preview. Open the full page to use this tool."; + return introduction; + } } class MjjJrpgShell extends HTMLElement {} @@ -792,10 +1482,59 @@ const chat = shell?.querySelector("mjj-jrpg-chat"); const composer = shell?.querySelector("mjj-jrpg-composer"); const preview = shell?.querySelector("mjj-jrpg-preview"); + const frameNetwork = shell?.querySelector("[data-frame-network]"); + const frameUptime = shell?.querySelector("[data-frame-uptime]"); + const minimizeButton = shell?.querySelector("[data-frame-minimize]"); + const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); let characterTimer = 0; let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); let activeController = null; composer?.setDisabled(true); + const setFrameStatus = (label, state, title = "") => { + if (frameNetwork) { + frameNetwork.textContent = label; + frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); + frameNetwork.dataset.state = state; + frameNetwork.title = title; + } + }; + setFrameStatus("ONLINE", "online"); + const startedAt = Date.now(); + const updateUptime = () => { + const elapsed = Math.floor((Date.now() - startedAt) / 1000); + const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); + const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); + const seconds = String(elapsed % 60).padStart(2, "0"); + if (frameUptime) { + frameUptime.textContent = `${hours}:${minutes}:${seconds}`; + } + }; + updateUptime(); + window.setInterval(updateUptime, 1000); + + minimizeButton?.addEventListener("click", () => { + const minimized = shell.dataset.minimized !== "true"; + shell.dataset.minimized = String(minimized); + minimizeButton.setAttribute("aria-pressed", String(minimized)); + minimizeButton.setAttribute( + "aria-label", + minimized ? "Restore interface" : "Minimize interface", + ); + }); + fullscreenButton?.addEventListener("click", async () => { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + else await shell.requestFullscreen(); + } catch (error) { + setFrameStatus("DENIED", "offline", error.message); + } + }); + document.addEventListener("fullscreenchange", () => { + fullscreenButton?.setAttribute( + "aria-pressed", + String(Boolean(document.fullscreenElement)), + ); + }); if (conversationId) { try { @@ -911,6 +1650,7 @@ } chat?.setMessageStreaming(assistantItem, false); character?.setAttribute("state", "default"); + setFrameStatus("ONLINE", "online"); } catch (error) { const aborted = error instanceof DOMException && error.name === "AbortError"; assistantText.cancel(); @@ -920,6 +1660,7 @@ aborted ? "Quest cancelled." : `System error: ${error.message}`, ); character?.setAttribute("state", "sad"); + if (!aborted) setFrameStatus("OFFLINE", "offline"); } finally { composer?.setBusy(false); composer?.querySelector("textarea")?.focus();