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);