# HG changeset patch # User MrJuneJune # Date 1786041090 25200 # Node ID ee04e4e69fed901648fa2e07ebba58a2c3225443 # Parent 0f45474c1b1aa9a8ec6ec782a9c43c022d133e73 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 <223556219+Copilot@users.noreply.github.com> diff -r 0f45474c1b1a -r ee04e4e69fed mrjunejune/main.c --- a/mrjunejune/main.c Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/main.c Thu Aug 06 11:31:30 2026 -0700 @@ -691,6 +691,67 @@ return resp; } +static boolean Converted_Filename_Is_Valid(const char *filename) +{ + if (!filename) + return FALSE; + const char *extension = strrchr(filename, '.'); + if (!extension || (strcmp(extension, ".webp") != 0 && + strcmp(extension, ".mp4") != 0)) + return FALSE; + if ((size_t)(extension - filename) != 36) + return FALSE; + for (size_t i = 0; i < 36; i++) + { + char value = filename[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) + { + if (value != '-') + return FALSE; + continue; + } + if (!((value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f') || + (value >= 'A' && value <= 'F'))) + return FALSE; + } + return TRUE; +} + +static Seobeo_Request_Entry *Converted_File_Error( + Dowa_Arena *arena, + char *status, + char *message) +{ + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", status, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", message, arena); + return resp; +} + +Seobeo_Request_Entry *DeleteConvertedFile( + Seobeo_Request_Entry *req, + Dowa_Arena *arena) +{ + void *filename_kv = Dowa_HashMap_Get_Ptr(req, ":filename"); + const char *filename = filename_kv + ? ((Seobeo_Request_Entry *)filename_kv)->value + : NULL; + if (!Converted_Filename_Is_Valid(filename)) + return Converted_File_Error(arena, "400", "Invalid converted filename"); + + char filepath[512]; + snprintf(filepath, sizeof(filepath), "/tmp/%s", filename); + if (unlink(filepath) != 0 && errno != ENOENT) + return Converted_File_Error(arena, "500", "Unable to delete converted file"); + + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "204", arena); + Dowa_HashMap_Push_Arena(resp, "body", "", arena); + return resp; +} + Seobeo_Request_Entry *DownloadConvertedFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) { Seobeo_Request_Entry *resp = NULL; @@ -706,26 +767,8 @@ } const char *filename = ((Seobeo_Request_Entry*)filename_kv)->value; - - // TODO: Maybe check if the uuid is allowed or not? - // if (strlen(filename) != TMP_FILE_LENGTH) - // { - // char *error_msg = "Not Allowed Filename"; - // Dowa_HashMap_Push_Arena(resp, "status", "404", arena); - // Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - // Dowa_HashMap_Push_Arena(resp, "body", error_msg, arena); - // return resp; - // } - // boolean allowed = FALSE; - // for (int i = 0; i < Dowa_Array_Length(g_uuid4_array); i++) - // { - // if strcmp(g_uuid4_array, filename) - // { - // allowed = TRUE; - // break; - // } - // g_uuid4_array++; - // } + if (!Converted_Filename_Is_Valid(filename)) + return Converted_File_Error(arena, "400", "Invalid converted filename"); char filepath[512]; snprintf(filepath, sizeof(filepath), "/tmp/%s", filename); @@ -759,8 +802,8 @@ file_data[file_size] = '\0'; fclose(file); - const char *content_type = "application/octet-stream"; - if (strstr(filename, ".webp")) + char *content_type = "application/octet-stream"; + if (strcmp(strrchr(filename, '.'), ".webp") == 0) content_type = "image/webp"; else if (strstr(filename, ".mp4")) content_type = "video/mp4"; @@ -2067,6 +2110,7 @@ Seobeo_Router_Register("POST", "/api/convert/image-to-webp", ConvertImageToWebP); Seobeo_Router_Register("POST", "/api/convert/video-to-mp4", ConvertVideoToMP4); Seobeo_Router_Register("GET", "/api/download/:filename", DownloadConvertedFile); + Seobeo_Router_Register("DELETE", "/api/download/:filename", DeleteConvertedFile); Seobeo_Router_Register("POST", "/api/latex/render", RenderLatexPdf); // -- S3 Upload --/ diff -r 0f45474c1b1a -r ee04e4e69fed mrjunejune/src/jrpg/index.html --- a/mrjunejune/src/jrpg/index.html Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/jrpg/index.html Thu Aug 06 11:31:30 2026 -0700 @@ -3,6 +3,7 @@ Shiba Quest | MrJuneJune {{/parts/base_head.html}} + @@ -11,6 +12,47 @@
+
+
+

+ MrJuneJune +

+
+
+ +
+ N/A + JUNE + ONLINE + +
+
@@ -59,15 +101,6 @@
-
-
-

- MrJuneJune -

-
-

SHIBA QUEST TERMINAL

-
-

CHARACTER RECORD

@@ -89,32 +122,26 @@ -
-
-
TYPE
-
Profile
-
-
-
STATUS
-
Available
-
-
- - - +

DESTINATION DATA

- - - +
+ + + Open page + + + + + + +

Resume

Experience, projects, and the systems I have helped build.

@@ -143,13 +170,18 @@
-
- - - Open full page - - - + +
Download PDF @@ -177,13 +209,13 @@ >
- + - + +
+

Video to MP4

+ + + + + + + + +
+
+ + `; + 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 = ` +
+
+
+

HLS source

+ + + + +
+ + + + + + +
+

Loading player engine...

+
+
+

Playback

+ + +
+
+
+ `; + 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 = ` +
+

+ Edit the source, then compile the PDF. +

+
+
+
+

LaTeX source

+ +
+ + + + +
+ + + + +
+
+
+

PDF preview

+ + +
+
+
+ `; + 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(); diff -r 0f45474c1b1a -r ee04e4e69fed mrjunejune/src/public/jrpg/background-frame-2.webp Binary file mrjunejune/src/public/jrpg/background-frame-2.webp has changed diff -r 0f45474c1b1a -r ee04e4e69fed mrjunejune/src/sw.js --- a/mrjunejune/src/sw.js Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/sw.js Thu Aug 06 11:31:30 2026 -0700 @@ -1,5 +1,5 @@ // Root-scoped Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v14-blog-browser'; +const CACHE_VERSION = 'v30-card-driven-details'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; // Files to cache immediately on install diff -r 0f45474c1b1a -r ee04e4e69fed mrjunejune/test/BUILD --- a/mrjunejune/test/BUILD Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/test/BUILD Thu Aug 06 11:31:30 2026 -0700 @@ -124,6 +124,7 @@ "//hg-web/e2e:node_modules/playwright-core", "//mrjunejune:mrjunejune_server_debug", ":inference_bridge_fake_sidecar", + "shiba.webp", "@playwright_chromium_linux//:chrome", "@playwright_chromium_linux//:chromium", ], diff -r 0f45474c1b1a -r ee04e4e69fed mrjunejune/test/snapshots/jrpg.snapshot --- a/mrjunejune/test/snapshots/jrpg.snapshot Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/test/snapshots/jrpg.snapshot Thu Aug 06 11:31:30 2026 -0700 @@ -32,6 +32,7 @@ + @@ -40,6 +41,47 @@
+
+
+

+ MrJuneJune +

+
+
+ +
+ N/A + JUNE + ONLINE + +
+
@@ -88,15 +130,6 @@
-
-
-

- MrJuneJune -

-
-

SHIBA QUEST TERMINAL

-
-

CHARACTER RECORD

@@ -118,32 +151,26 @@ -
-
-
TYPE
-
Profile
-
-
-
STATUS
-
Available
-
-
- - - +

DESTINATION DATA

- - - +
+ + + Open page + + + + + + +

Resume

Experience, projects, and the systems I have helped build.

@@ -172,13 +199,18 @@
-
- - - Open full page - - - + +
Download PDF @@ -206,13 +238,13 @@ >
- + -