diff --git a/common/layout.py b/common/layout.py
index b9c39bb..81f5c74 100644
--- a/common/layout.py
+++ b/common/layout.py
@@ -337,7 +337,7 @@ def Page(
" htmx.config.scrollBehavior = 'smooth';\n"
" htmx.config.selfRequestsOnly = false;\n"
" \n"
- f' \n'
+ f' \n'
f" {django_htmx_script(nonce=None)}\n"
f' \n'
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
diff --git a/games/static/js/htmx-redirect-toast.js b/games/static/js/htmx-redirect-toast.js
deleted file mode 100644
index fb0eab1..0000000
--- a/games/static/js/htmx-redirect-toast.js
+++ /dev/null
@@ -1,37 +0,0 @@
-(function() {
- htmx.defineExtension("hx-redirect-toast", {
- isInlineSwap: function(swapStyle) {
- return swapStyle === "hx-redirect-toast";
- },
- handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
- var xhr = htmxConfig.xhr;
- var hxRedirect = xhr.getResponseHeader("HX-Redirect");
- var hxTrigger = xhr.getResponseHeader("HX-Trigger");
-
- // Redirect immediately (toast will be shown on the new page)
- if (hxRedirect) {
- window.location.href = hxRedirect;
- }
-
- // Only dispatch HX-Trigger events for toasts when not redirecting
- if (!hxRedirect && hxTrigger) {
- var triggers = JSON.parse(hxTrigger);
- var events = Array.isArray(triggers) ? triggers : [triggers];
- events.forEach(function(triggerObj) {
- Object.entries(triggerObj).forEach(function(entry) {
- var name = entry[0];
- var detail = entry[1];
- try { detail = JSON.parse(detail); } catch(e) {}
- target.dispatchEvent(new CustomEvent(name, {
- detail: detail,
- bubbles: true,
- cancelable: true
- }));
- });
- });
- }
- // Return null to prevent any DOM swap
- return null;
- }
- });
-})();
diff --git a/games/static/js/utils.js b/games/static/js/utils.js
deleted file mode 100644
index 82f9d15..0000000
--- a/games/static/js/utils.js
+++ /dev/null
@@ -1,238 +0,0 @@
-/**
- * @description Runs initializeElement once for each element matching selector,
- * on initial page load and inside every htmx-swapped fragment (a port of
- * FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
- * each swapped-in element, so a single registration covers both; the WeakSet
- * guarantees once-per-element initialization, replacing the old
- * DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
- * @param {string} selector
- * @param {function(Element): void} initializeElement
- */
-function onSwap(selector, initializeElement) {
- const initialized = new WeakSet();
- htmx.onLoad((swappedElement) => {
- const elements = Array.from(htmx.findAll(swappedElement, selector));
- if (swappedElement.matches && swappedElement.matches(selector)) {
- elements.unshift(swappedElement);
- }
- for (const element of elements) {
- if (initialized.has(element)) continue;
- initialized.add(element);
- initializeElement(element);
- }
- });
-}
-
-/**
- * @description Formats Date to a UTC string accepted by the datetime-local input field.
- * @param {Date} date
- * @returns {string}
- */
-function toISOUTCString(date) {
- function stringAndPad(number) {
- return number.toString().padStart(2, 0);
- }
- const year = date.getFullYear();
- const month = stringAndPad(date.getMonth() + 1);
- const day = stringAndPad(date.getDate());
- const hours = stringAndPad(date.getHours());
- const minutes = stringAndPad(date.getMinutes());
- return `${year}-${month}-${day}T${hours}:${minutes}`;
-}
-
-/**
- * @description Sync values between source and target elements based on syncData configuration.
- * @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
- */
-function syncSelectInputUntilChanged(syncData, parentSelector = document) {
- const parentElement =
- parentSelector === document
- ? document
- : document.querySelector(parentSelector);
-
- if (!parentElement) {
- console.error(`The parent selector "${parentSelector}" is not valid.`);
- return;
- }
- // Set up a single change event listener on the document for handling all source changes
- parentElement.addEventListener("change", function (event) {
- // Loop through each sync configuration item
- syncData.forEach((syncItem) => {
- // Check if the change event target matches the source selector
- if (event.target.matches(syncItem.source)) {
- const sourceElement = event.target;
- const valueToSync = getValueFromProperty(
- sourceElement,
- syncItem.source_value
- );
- const targetElement = document.querySelector(syncItem.target);
-
- if (targetElement && valueToSync !== null) {
- console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
- targetElement[syncItem.target_value] = valueToSync;
- }
- }
- });
- });
-
- // Set up a single focus event listener on the document for handling all target focuses
- parentElement.addEventListener(
- "focus",
- function (event) {
- // Loop through each sync configuration item
- syncData.forEach((syncItem) => {
- // Check if the focus event target matches the target selector
- if (event.target.matches(syncItem.target)) {
- // Remove the change event listener to stop syncing
- // This assumes you want to stop syncing once any target receives focus
- // You may need a more sophisticated way to remove listeners if you want to stop
- // syncing selectively based on other conditions
- document.removeEventListener("change", syncSelectInputUntilChanged);
- }
- });
- },
- true
- ); // Use capture phase to ensure the event is captured during focus, not bubble
-}
-
-/**
- * @description Retrieve the value from the source element based on the provided property.
- * @param {Element} sourceElement - The source HTML element.
- * @param {string} property - The property to retrieve the value from.
- */
-function getValueFromProperty(sourceElement, property) {
- let source =
- sourceElement instanceof HTMLSelectElement
- ? sourceElement.selectedOptions[0]
- : sourceElement;
- if (property.startsWith("dataset.")) {
- let datasetKey = property.slice(8); // Remove 'dataset.' part
- return source.dataset[datasetKey];
- } else if (property in source) {
- return source[property];
- } else {
- console.error(`Property ${property} is not valid for the option element.`);
- return null;
- }
-}
-
-/**
- * @description Returns a single element by name.
- * @param {string} selector The selector to look for.
- */
-function getEl(selector) {
- if (selector.startsWith("#")) {
- return document.getElementById(selector.slice(1));
- } else if (selector.startsWith(".")) {
- return document.getElementsByClassName(selector);
- } else {
- return document.getElementsByTagName(selector);
- }
-}
-
-/**
- * @description Applies different behaviors to elements based on multiple conditional configurations.
- * Each configuration is an array containing a condition function, an array of target element selectors,
- * and two callback functions for handling matched and unmatched conditions.
- * @param {...Array} configs Each configuration is an array of the form:
- * - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
- * - 1: {string[]} targetElements - Array of CSS selectors for target elements.
- * - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
- * - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
- */
-function conditionalElementHandler(...configs) {
- configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
- if (condition()) {
- targetElements.forEach((elementName) => {
- let el = getEl(elementName);
- if (el === null) {
- console.error(`Element ${elementName} doesn't exist.`);
- } else {
- callbackfn1(el);
- }
- });
- } else {
- targetElements.forEach((elementName) => {
- let el = getEl(elementName);
- if (el === null) {
- console.error(`Element ${elementName} doesn't exist.`);
- } else {
- callbackfn2(el);
- }
- });
- }
- });
-}
-
-function disableElementsWhenValueNotEqual(
- targetSelect,
- targetValue,
- elementList
-) {
- return conditionalElementHandler([
- () => {
- let target = getEl(targetSelect);
- console.debug(
- `${disableElementsWhenTrue.name}: triggered on ${target.id}`
- );
- console.debug(`
- ${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
- if (targetValue instanceof Array) {
- if (targetValue.every((value) => target.value != value)) {
- console.debug(
- `${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
- );
- return true;
- }
- } else {
- console.debug(
- `${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
- );
- return target.value != targetValue;
- }
- },
- elementList,
- (el) => {
- console.debug(
- `${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
- );
- el.disabled = "disabled";
- },
- (el) => {
- console.debug(
- `${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
- );
- el.disabled = "";
- },
- ]);
-}
-
-function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
- return conditionalElementHandler([
- () => {
- console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
- console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
- return getEl(targetSelect).value == targetValue;
- },
- elementList,
- (el) => {
- console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
- el.disabled = "disabled";
- },
- (el) => {
- console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
- el.disabled = "";
- },
- ]);
-}
-
-export {
- onSwap,
- toISOUTCString,
- syncSelectInputUntilChanged,
- getEl,
- conditionalElementHandler,
- disableElementsWhenValueNotEqual,
- disableElementsWhenTrue,
- getValueFromProperty,
-};
diff --git a/ts/htmx-redirect-toast.ts b/ts/htmx-redirect-toast.ts
new file mode 100644
index 0000000..1413250
--- /dev/null
+++ b/ts/htmx-redirect-toast.ts
@@ -0,0 +1,60 @@
+/**
+ * htmx "hx-redirect-toast" extension.
+ *
+ * A custom swap style that performs no DOM swap. On an HX-Redirect response it
+ * navigates immediately (the toast shows on the destination page); otherwise it
+ * turns the HX-Trigger header into CustomEvents so toasts fire in place.
+ *
+ * Classic (non-module) script: it only touches the global htmx and registers an
+ * extension, so it stays a plain