From 247abc3475aba766280b51dc7291cd20c5d0932c Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 7 Oct 2026 19:04:35 -0700 Subject: [PATCH] fix: tags deep link renders and admin modals close admin.html loaded tags-page.js after admin.js, so opening the page at #/tags found no TagsPageManager and rendered nothing; the script order is now theme-bridge, html-escape, api-client, png-metadata, (list-state, sse-stream, data-actions), viewer, comfy-metadata, prompt-list-sort, view-router, tags-page, admin. Route resolution lives in the pure ViewRouter.resolveView(). The auto-tag modal's close buttons called a bare hideModal(), which only gallery.js defines; they call window.admin.hideModal(). PromptAdmin is constructed from startAdmin() on DOMContentLoaded (or immediately when the document is already parsed) instead of at script evaluation time. --- tests/js/view-router.test.js | 30 +++++++++++++++++ web/admin.html | 7 ++-- web/js/admin.js | 64 ++++++++++++++++++++---------------- web/js/view-router.js | 27 +++++++++++++++ 4 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 tests/js/view-router.test.js create mode 100644 web/js/view-router.js diff --git a/tests/js/view-router.test.js b/tests/js/view-router.test.js new file mode 100644 index 0000000..10c051b --- /dev/null +++ b/tests/js/view-router.test.js @@ -0,0 +1,30 @@ +// Run with: node --test tests/js/ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { resolveView, VIEWS } = require("../../web/js/view-router.js"); + +test("the tags deep link and its sub-routes resolve to the tags view", () => { + assert.equal(resolveView("#/tags"), "tags"); + assert.equal(resolveView("#/tags/"), "tags"); + assert.equal(resolveView("#/tags/landscape,portrait?mode=or"), "tags"); +}); + +test("an empty, bare or unknown hash resolves to the prompts dashboard", () => { + assert.equal(resolveView(""), "prompts"); + assert.equal(resolveView("#"), "prompts"); + assert.equal(resolveView("#/"), "prompts"); + assert.equal(resolveView("#/other"), "prompts"); + assert.equal(resolveView("#/tagsX"), "prompts", "prefix must end at a path boundary"); + assert.equal(resolveView("#tags"), "prompts"); +}); + +test("non-string input resolves to the prompts dashboard", () => { + assert.equal(resolveView(undefined), "prompts"); + assert.equal(resolveView(null), "prompts"); + assert.equal(resolveView(42), "prompts"); +}); + +test("every resolved value is a listed view", () => { + assert.ok(Object.isFrozen(VIEWS)); + for (const hash of ["#/tags", "", "#/x"]) assert.ok(VIEWS.includes(resolveView(hash))); +}); diff --git a/web/admin.html b/web/admin.html index c2c6e6e..2f08277 100644 --- a/web/admin.html +++ b/web/admin.html @@ -1043,7 +1043,7 @@

Auto Tag Images

- @@ -1399,7 +1399,8 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr - + + diff --git a/web/js/admin.js b/web/js/admin.js index 877b0be..eb716bd 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -60,8 +60,7 @@ } handleRoute() { - const hash = window.location.hash; - if (hash === '#/tags' || hash.startsWith('#/tags/')) { + if (ViewRouter.resolveView(window.location.hash) === "tags") { this.showTagsPage(); } else { this.showDashboard(); @@ -3866,33 +3865,42 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } } - // Initialize the admin interface - const admin = new PromptAdmin(); - window.admin = admin; + /** Build the dashboard; needs the DOM, so it runs on DOMContentLoaded. */ + function startAdmin() { + // Initialize the admin interface + const admin = new PromptAdmin(); + window.admin = admin; - // Add event listeners for prompt selection - document.addEventListener("change", function (e) { - if (e.target.classList.contains("prompt-checkbox")) { - const promptId = parseInt(e.target.dataset.id); - if (e.target.checked) { - admin.selectedPrompts.add(promptId); - } else { - admin.selectedPrompts.delete(promptId); + // Add event listeners for prompt selection + document.addEventListener("change", function (e) { + if (e.target.classList.contains("prompt-checkbox")) { + const promptId = parseInt(e.target.dataset.id); + if (e.target.checked) { + admin.selectedPrompts.add(promptId); + } else { + admin.selectedPrompts.delete(promptId); + } + admin.updateBulkActionButtons(); + + const allCheckboxes = document.querySelectorAll(".prompt-checkbox"); + const checkedCheckboxes = document.querySelectorAll(".prompt-checkbox:checked"); + const selectAllCheckbox = document.getElementById("selectAll"); + selectAllCheckbox.checked = allCheckboxes.length === checkedCheckboxes.length; + selectAllCheckbox.indeterminate = checkedCheckboxes.length > 0 && checkedCheckboxes.length < allCheckboxes.length; } - admin.updateBulkActionButtons(); + }); - const allCheckboxes = document.querySelectorAll(".prompt-checkbox"); - const checkedCheckboxes = document.querySelectorAll(".prompt-checkbox:checked"); - const selectAllCheckbox = document.getElementById("selectAll"); - selectAllCheckbox.checked = allCheckboxes.length === checkedCheckboxes.length; - selectAllCheckbox.indeterminate = checkedCheckboxes.length > 0 && checkedCheckboxes.length < allCheckboxes.length; - } - }); + // Escape closes the gallery modal + document.addEventListener('keydown', function (e) { + const galleryModal = document.getElementById('galleryModal'); + if (e.key === 'Escape' && galleryModal && !galleryModal.classList.contains('hidden')) { + admin.closeGallery(); + } + }); + } - // Escape closes the gallery modal - document.addEventListener('keydown', function (e) { - const galleryModal = document.getElementById('galleryModal'); - if (e.key === 'Escape' && galleryModal && !galleryModal.classList.contains('hidden')) { - admin.closeGallery(); - } - }); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startAdmin); + } else { + startAdmin(); + } diff --git a/web/js/view-router.js b/web/js/view-router.js new file mode 100644 index 0000000..35512ad --- /dev/null +++ b/web/js/view-router.js @@ -0,0 +1,27 @@ +/** + * Hash-route resolution for the admin page: which top-level view a + * location.hash selects. Kept pure so the #/tags deep link is testable. + * + * Loadable in the browser (window.ViewRouter) and in Node for tests. + */ +(function (root) { + "use strict"; + + const VIEWS = Object.freeze(["prompts", "tags"]); + const TAGS_PREFIX = "#/tags"; + + /** + * @param {string} hash - window.location.hash, including the leading "#" + * @returns {"prompts"|"tags"} + */ + function resolveView(hash) { + if (typeof hash !== "string") return "prompts"; + if (hash === TAGS_PREFIX) return "tags"; + if (hash.startsWith(TAGS_PREFIX + "/")) return "tags"; + return "prompts"; + } + + const api = { resolveView, VIEWS }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.ViewRouter = api; +})(typeof window !== "undefined" ? window : globalThis);