diff --git a/assets/css/input.css b/assets/css/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/assets/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/assets/css/tailwind.config.js b/assets/css/tailwind.config.js new file mode 100644 index 0000000..575381c --- /dev/null +++ b/assets/css/tailwind.config.js @@ -0,0 +1,14 @@ +const {join} = require("node:path"); +/** @type {import('tailwindcss').Config} */ +const root = join(__dirname, "../../"); +const content = join(root, "**/*.go"); + +console.log(content) + +module.exports = { + content: [content], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/js/.prettierignore b/assets/js/.prettierignore similarity index 100% rename from js/.prettierignore rename to assets/js/.prettierignore diff --git a/js/extensions/debug.ts b/assets/js/extensions/debug.ts similarity index 55% rename from js/extensions/debug.ts rename to assets/js/extensions/debug.ts index 4305002..90a841f 100644 --- a/js/extensions/debug.ts +++ b/assets/js/extensions/debug.ts @@ -1,11 +1,12 @@ -import * as htmx from "htmx.org"; +import htmx from "htmx.org"; htmx.defineExtension("debug", { + // @ts-ignore onEvent: function (name, evt) { if (console.debug) { - console.debug(name, evt.target, evt); + console.debug(name); } else if (console) { - console.log("DEBUG:", name, evt.target, evt); + console.log("DEBUG:", name); } else { // noop } diff --git a/assets/js/extensions/mutation-error.ts b/assets/js/extensions/mutation-error.ts new file mode 100644 index 0000000..caf9af1 --- /dev/null +++ b/assets/js/extensions/mutation-error.ts @@ -0,0 +1,21 @@ +import htmx from "htmx.org"; + +htmx.defineExtension("mutation-error", { + // @ts-ignore + onEvent: (name, evt) => { + if (!(evt instanceof CustomEvent)) { + return false; + } + if (name === "htmx:afterRequest") { + if (!evt.detail || !evt.detail.xhr) { + return; + } + const status = evt.detail.xhr.status; + if (status >= 400) { + htmx.findAll("[hx-on\\:\\:mutation-error]").forEach((element) => { + htmx.trigger(element, "htmx:mutation-error", { status }); + }); + } + } + }, +}); diff --git a/js/extensions/pathdeps.ts b/assets/js/extensions/pathdeps.ts similarity index 92% rename from js/extensions/pathdeps.ts rename to assets/js/extensions/pathdeps.ts index 7e66d44..0526abd 100644 --- a/js/extensions/pathdeps.ts +++ b/assets/js/extensions/pathdeps.ts @@ -1,4 +1,4 @@ -import * as htmx from "htmx.org"; +import htmx from "htmx.org"; function dependsOn(pathSpec: any, url: string) { if (pathSpec === "ignore") { @@ -33,7 +33,11 @@ function refreshPath(path: string) { } htmx.defineExtension("path-deps", { + // @ts-ignore onEvent: function (name, evt) { + if (!(evt instanceof CustomEvent)) { + return false; + } if (name === "htmx:beforeOnLoad") { const config = evt.detail.requestConfig; // mutating call diff --git a/assets/js/extensions/response-targets.ts b/assets/js/extensions/response-targets.ts new file mode 100644 index 0000000..1753a35 --- /dev/null +++ b/assets/js/extensions/response-targets.ts @@ -0,0 +1,136 @@ +import htmx from "htmx.org"; +const config: any = htmx.config; + +/** @type {import("../htmx").HtmxInternalApi} */ +let api: any; + +const attrPrefix = "hx-target-"; + +// IE11 doesn't support string.startsWith +function startsWith(str: string, prefix: string) { + return str.substring(0, prefix.length) === prefix; +} + +/** + * @param {HTMLElement} elt + * @param respCodeNumber + * @returns {HTMLElement | null} + */ +function getRespCodeTarget(elt: Element, respCodeNumber: number) { + if (!elt || !respCodeNumber) return null; + + const respCode = respCodeNumber.toString(); + + // '*' is the original syntax, as the obvious character for a wildcard. + // The 'x' alternative was added for maximum compatibility with HTML + // templating engines, due to ambiguity around which characters are + // supported in HTML attributes. + // + // Start with the most specific possible attribute and generalize from + // there. + const attrPossibilities = [ + respCode, + + respCode.substr(0, 2) + "*", + respCode.substr(0, 2) + "x", + + respCode.substr(0, 1) + "*", + respCode.substr(0, 1) + "x", + respCode.substr(0, 1) + "**", + respCode.substr(0, 1) + "xx", + + "*", + "x", + "***", + "xxx", + ]; + if (startsWith(respCode, "4") || startsWith(respCode, "5")) { + attrPossibilities.push("error"); + } + + for (let i = 0; i < attrPossibilities.length; i++) { + const attr = attrPrefix + attrPossibilities[i]; + const attrValue = api.getClosestAttributeValue(elt, attr); + if (attrValue) { + if (attrValue === "this") { + return api.findThisElement(elt, attr); + } else { + return api.querySelectorExt(elt, attrValue); + } + } + } + + return null; +} + +/** @param {Event} evt */ +function handleErrorFlag(evt: CustomEvent) { + if (evt.detail.isError) { + if (config.responseTargetUnsetsError) { + evt.detail.isError = false; + } + } else if (config.responseTargetSetsError) { + evt.detail.isError = true; + } +} + +htmx.defineExtension("response-targets", { + // @ts-ignore + init: (apiRef) => { + api = apiRef; + + if (config.responseTargetUnsetsError === undefined) { + config.responseTargetUnsetsError = true; + } + if (config.responseTargetSetsError === undefined) { + config.responseTargetSetsError = false; + } + if (config.responseTargetPrefersExisting === undefined) { + config.responseTargetPrefersExisting = false; + } + if (config.responseTargetPrefersRetargetHeader === undefined) { + config.responseTargetPrefersRetargetHeader = true; + } + }, + + // @ts-ignore + onEvent: (name, evt) => { + if (!(evt instanceof CustomEvent)) { + return false; + } + if ( + name === "htmx:beforeSwap" && + evt.detail.xhr && + evt.detail.xhr.status !== 200 + ) { + if (evt.detail.target) { + if (config.responseTargetPrefersExisting) { + evt.detail.shouldSwap = true; + handleErrorFlag(evt); + return true; + } + if ( + config.responseTargetPrefersRetargetHeader && + evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i) + ) { + evt.detail.shouldSwap = true; + handleErrorFlag(evt); + return true; + } + } + if (!evt.detail.requestConfig) { + return true; + } + const target = getRespCodeTarget( + evt.detail.requestConfig.elt, + evt.detail.xhr.status, + ); + if (target) { + handleErrorFlag(evt); + evt.detail.shouldSwap = true; + evt.detail.target = target; + } + return true; + } + }, +}); diff --git a/assets/js/extensions/trigger-children.ts b/assets/js/extensions/trigger-children.ts new file mode 100644 index 0000000..f57e4a5 --- /dev/null +++ b/assets/js/extensions/trigger-children.ts @@ -0,0 +1,43 @@ +import htmx, { HtmxSettleInfo, HtmxSwapStyle } from "htmx.org"; + +htmx.defineExtension("trigger-children", { + onEvent: (name, evt: Event | CustomEvent) => { + if (!(evt instanceof CustomEvent)) { + return false; + } + const target = evt.detail.target as HTMLElement; + if (target && target.children) { + Array.from(target.children).forEach((e) => { + htmx.trigger(e, name, null); + }); + } + return true; + }, + init: function (api: any): void {}, + transformResponse: function ( + text: string, + xhr: XMLHttpRequest, + elt: Element, + ): string { + return text; + }, + isInlineSwap: function (swapStyle: HtmxSwapStyle): boolean { + return false; + }, + handleSwap: function ( + swapStyle: HtmxSwapStyle, + target: Node, + fragment: Node, + settleInfo: HtmxSettleInfo, + ): boolean | Node[] { + return false; + }, + encodeParameters: function ( + xhr: XMLHttpRequest, + parameters: FormData, + elt: Node, + ) {}, + getSelectors: function (): string[] | null { + return null; + }, +}); diff --git a/js/mhtml.ts b/assets/js/mhtml.ts similarity index 83% rename from js/mhtml.ts rename to assets/js/mhtml.ts index 81d92af..51bea3a 100644 --- a/js/mhtml.ts +++ b/assets/js/mhtml.ts @@ -1,18 +1,9 @@ -import * as htmx from "htmx.org"; +import htmx from "htmx.org"; import "./extensions/pathdeps"; import "./extensions/trigger-children"; import "./extensions/debug"; - -declare module "htmx.org" { - // Example: Adding type definitions for an exported function - export function swap( - target: Element, - content: string, - spec?: { - swapStyle?: "innerHTML" | "outerHTML"; - }, - ): any; -} +import "./extensions/response-targets"; +import "./extensions/mutation-error"; function watchUrl(callback: (oldUrl: string, newUrl: string) => void) { let lastUrl = window.location.href; @@ -38,7 +29,11 @@ function onUrlChange(newUrl: string) { } const split = triggers.split(", "); if (split.find((s) => s === "url")) { - htmx.swap(element, "url"); + htmx.swap(element, "url", { + swapStyle: "outerHTML", + swapDelay: 0, + settleDelay: 0, + }); } else { for (let [key, values] of url.searchParams) { let eventName = "qs:" + key; @@ -60,6 +55,8 @@ function onUrlChange(newUrl: string) { if (value) { htmx.swap(el, el.getAttribute(name) ?? "", { swapStyle: "innerHTML", + swapDelay: 0, + settleDelay: 0, }); hasMatch = true; break; @@ -72,7 +69,7 @@ function onUrlChange(newUrl: string) { htmx.swap( el, el.getAttribute("hx-match-qp-mapping:" + defaultKey) ?? "", - { swapStyle: "innerHTML" }, + { swapStyle: "innerHTML", swapDelay: 0, settleDelay: 0 }, ); } } diff --git a/js/package-lock.json b/assets/js/package-lock.json similarity index 84% rename from js/package-lock.json rename to assets/js/package-lock.json index b11a899..ffeb3b8 100644 --- a/js/package-lock.json +++ b/assets/js/package-lock.json @@ -9,16 +9,29 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "htmx.org": "^1.9.12" + "htmx.org": "~2.0.2" }, "devDependencies": { "@swc/core": "^1.7.26", "@types/node": "^22.5.4", "prettier": "^3.3.3", + "tailwindcss": "^3.4.11", "tsup": "^8.2.4", "typescript": "^5.6.2" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -992,6 +1005,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1064,6 +1083,15 @@ "node": ">=8" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1138,6 +1166,18 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -1155,6 +1195,12 @@ } } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1167,6 +1213,12 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1320,6 +1372,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -1384,10 +1445,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/htmx.org": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz", - "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==" + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.2.tgz", + "integrity": "sha512-eUPIpQaWKKstX393XNCRCMJTrqPzikh36Y9RceqsUZLTtlFjFaVDgwZLUsrFk8J2uzZxkkfiy0TE359j2eN6hA==" }, "node_modules/human-signals": { "version": "2.1.0", @@ -1419,6 +1492,21 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1491,6 +1579,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1617,6 +1714,24 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1647,6 +1762,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -1677,6 +1801,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -1720,6 +1850,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -1729,6 +1868,70 @@ "node": ">= 6" } }, + "node_modules/postcss": { + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -1771,6 +1974,50 @@ } } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, "node_modules/prettier": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", @@ -1815,6 +2062,15 @@ } ] }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1827,6 +2083,23 @@ "node": ">=8.10.0" } }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1952,6 +2225,15 @@ "node": ">= 8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2079,6 +2361,123 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", + "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2206,6 +2605,12 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -2328,6 +2733,18 @@ "engines": { "node": ">=8" } + }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } } } } diff --git a/js/package.json b/assets/js/package.json similarity index 70% rename from js/package.json rename to assets/js/package.json index 798e68f..04bd9bc 100644 --- a/js/package.json +++ b/assets/js/package.json @@ -5,19 +5,21 @@ "scripts": { "watch": "tsup ./mhtml.ts --watch --config ./tsup.config.ts", "build": "tsup ./mhtml.ts --minify --config ./tsup.config.ts", + "tailwind:watch": "npx tailwindcss -i ./input.css -o ./output.css --watch", "pretty": "prettier --write ." }, "author": "", "license": "ISC", "description": "", "dependencies": { - "htmx.org": "^1.9.12" + "htmx.org": "~2.0.2" }, "devDependencies": { "@swc/core": "^1.7.26", "@types/node": "^22.5.4", + "prettier": "^3.3.3", + "tailwindcss": "^3.4.11", "tsup": "^8.2.4", - "typescript": "^5.6.2", - "prettier": "^3.3.3" + "typescript": "^5.6.2" } } diff --git a/js/tsconfig.json b/assets/js/tsconfig.json similarity index 89% rename from js/tsconfig.json rename to assets/js/tsconfig.json index baf0d7f..75dd027 100644 --- a/js/tsconfig.json +++ b/assets/js/tsconfig.json @@ -10,6 +10,6 @@ "strict": true, "forceConsistentCasingInFileNames": true }, - "include": ["./*.ts"], + "include": ["./*.ts", "./*.js"], "exclude": ["node_modules"] } diff --git a/js/tsup.config.ts b/assets/js/tsup.config.ts similarity index 76% rename from js/tsup.config.ts rename to assets/js/tsup.config.ts index a7bfb5f..208f6fd 100644 --- a/js/tsup.config.ts +++ b/assets/js/tsup.config.ts @@ -3,19 +3,21 @@ import { defineConfig } from "tsup"; export default defineConfig({ format: ["esm"], entry: ["./src/mhtml.ts"], - outDir: "./dist", + outDir: "./../dist", dts: false, shims: true, skipNodeModulesBundle: true, - clean: true, - target: "es6", + clean: false, + target: "esnext", + treeshake: false, + sourcemap: true, platform: "browser", outExtension: () => { return { js: ".js", }; }, - minify: true, + minify: false, bundle: true, // https://github.com/egoist/tsup/issues/619 noExternal: [/(.*)/], diff --git a/features/patient/patient-service.go b/features/patient/patient-service.go new file mode 100644 index 0000000..d3737ce --- /dev/null +++ b/features/patient/patient-service.go @@ -0,0 +1,49 @@ +package patient + +import ( + "errors" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "mhtml/database" + "time" +) + +type Patient struct { + Name string + ReasonForVisit string + AppointmentDate time.Time + LocationName string +} + +type Service struct { + ctx *fiber.Ctx +} + +func NewService(ctx *fiber.Ctx) *Service { + return &Service{} +} + +type CreatePatientRequest struct { + Name string + ReasonForVisit string + LocationName string +} + +func (c *Service) Create(request CreatePatientRequest) error { + time.Sleep(time.Second) + database.HSet("patients", uuid.New().String(), Patient{ + Name: request.Name, + ReasonForVisit: request.ReasonForVisit, + AppointmentDate: time.Now(), + LocationName: "New York", + }) + return errors.New("error creating patient") +} + +func (c *Service) List() ([]*Patient, error) { + patients, err := database.HList[Patient]("patients") + if err != nil { + return nil, err + } + return patients, nil +} diff --git a/h/base.go b/h/base.go index be7423b..b508102 100644 --- a/h/base.go +++ b/h/base.go @@ -51,12 +51,8 @@ func NewPartial(root Renderable) *Partial { } } -func GetFunctionName(i interface{}) string { - return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() -} - func GetPartialPath(partial func(ctx *fiber.Ctx) *Partial) string { - return GetFunctionName(partial) + return runtime.FuncForPC(reflect.ValueOf(partial).Pointer()).Name() } func GetPartialPathWithQs(partial func(ctx *fiber.Ctx) *Partial, qs string) string { diff --git a/h/lifecycle.go b/h/lifecycle.go new file mode 100644 index 0000000..9fe9552 --- /dev/null +++ b/h/lifecycle.go @@ -0,0 +1,91 @@ +package h + +import ( + "fmt" +) + +var HxBeforeRequest = "hx-on::before-request" +var HxAfterRequest = "hx-on::after-request" +var HxOnMutationError = "hx-on::mutation-error" + +type LifeCycle struct { + beforeRequest []JsCommand + afterRequest []JsCommand + onMutationError []JsCommand +} + +func NewLifeCycle() *LifeCycle { + return &LifeCycle{ + beforeRequest: []JsCommand{}, + afterRequest: []JsCommand{}, + onMutationError: []JsCommand{}, + } +} + +func (l *LifeCycle) BeforeRequest(cmd ...JsCommand) *LifeCycle { + l.beforeRequest = append(l.beforeRequest, cmd...) + return l +} + +func (l *LifeCycle) AfterRequest(cmd ...JsCommand) *LifeCycle { + l.afterRequest = append(l.afterRequest, cmd...) + return l +} + +func (l *LifeCycle) OnMutationError(cmd ...JsCommand) *LifeCycle { + l.onMutationError = append(l.onMutationError, cmd...) + return l +} + +func (l *LifeCycle) Render() *Node { + beforeRequest := "" + afterReqest := "" + onMutationError := "" + for _, command := range l.beforeRequest { + beforeRequest += fmt.Sprintf("%s;", command.Command) + } + for _, command := range l.afterRequest { + afterReqest += fmt.Sprintf("%s;", command.Command) + } + for _, command := range l.onMutationError { + onMutationError += fmt.Sprintf("%s;", command.Command) + } + + return Children( + If(beforeRequest != "", Attribute(HxBeforeRequest, beforeRequest)), + If(afterReqest != "", Attribute(HxAfterRequest, afterReqest)), + If(onMutationError != "", Attribute(HxOnMutationError, onMutationError)), + ).Render() +} + +type JsCommand struct { + Command string +} + +func SetText(text string) JsCommand { + return JsCommand{Command: fmt.Sprintf("this.innerText = '%s'", text)} +} + +func AddAttribute(name, value string) JsCommand { + return JsCommand{Command: fmt.Sprintf("this.setAttribute('%s', '%s')", name, value)} +} + +func RemoveAttribute(name string) JsCommand { + return JsCommand{Command: fmt.Sprintf("this.removeAttribute('%s')", name)} +} + +func AddClass(class string) JsCommand { + return JsCommand{Command: fmt.Sprintf("this.classList.add('%s')", class)} +} + +func RemoveClass(class string) JsCommand { + return JsCommand{Command: fmt.Sprintf("this.classList.remove('%s')", class)} +} + +func Alert(text string) JsCommand { + return JsCommand{Command: fmt.Sprintf("alert('%s')", text)} +} + +func EvalJs(js string) JsCommand { + return JsCommand{Command: js} +} diff --git a/h/render.go b/h/render.go index fdd579e..c8dcc15 100644 --- a/h/render.go +++ b/h/render.go @@ -41,7 +41,13 @@ func (page Builder) renderNode(node *Node) { flatChildren := make([]Renderable, 0) for _, child := range node.children { + + if child == nil { + continue + } + c := child.Render() + flatChildren = append(flatChildren, child) if c.tag == FlagChildrenList { for _, gc := range c.children { diff --git a/h/tag.go b/h/tag.go index 9b4b50f..87b0fe5 100644 --- a/h/tag.go +++ b/h/tag.go @@ -276,6 +276,9 @@ func PushQsHeader(ctx *fiber.Ctx, key string, value string) *Headers { } func NewHeaders(headers ...string) *Headers { + if len(headers)%2 != 0 { + return &Headers{} + } m := make(Headers) for i := 0; i < len(headers); i++ { m[headers[i]] = headers[i+1] @@ -372,6 +375,10 @@ func BeforeRequestSetAttribute(key string, value string) Renderable { return Attribute("hx-on::before-request", `this.setAttribute('`+key+`', '`+value+`')`) } +func OnMutationErrorSetText(text string) Renderable { + return Attribute("hx-on::mutation-error", `this.innerText = '`+text+`'`) +} + func BeforeRequestSetText(text string) Renderable { return Attribute("hx-on::before-request", `this.innerText = '`+text+`'`) } diff --git a/js/extensions/trigger-children.ts b/js/extensions/trigger-children.ts deleted file mode 100644 index 99e5131..0000000 --- a/js/extensions/trigger-children.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as htmx from "htmx.org"; -import { HtmxEvent } from "htmx.org"; - -htmx.defineExtension("trigger-children", { - onEvent: (name: HtmxEvent, evt: CustomEvent) => { - const target = evt.detail.target as HTMLElement; - if (target && target.children) { - Array.from(target.children).forEach((e) => { - htmx.trigger(e, name, null); - }); - } - }, -}); diff --git a/justfile b/justfile index a984421..b2a29e3 100644 --- a/justfile +++ b/justfile @@ -9,4 +9,15 @@ watch-gen: go run ./tooling/watch.go --command 'go run ./tooling/astgen' watch-js: - cd js && npm run build && npm run watch \ No newline at end of file + cd assets/js && npm run build && npm run watch + +setup-tailwind-cli: + cd assets/css && curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64 + cd assets/css && chmod +x tailwindcss-macos-arm64 + cd assets/css && mv tailwindcss-macos-arm64 tailwindcss + +watch-css: + cd assets/css && ./tailwindcss -i input.css -o ./../dist/main.css --watch + +build-css: + cd assets/css && ./tailwindcss -i input.css -o ./../dist/main.css --minify \ No newline at end of file diff --git a/k6.js b/k6.js deleted file mode 100644 index 0efa8e5..0000000 --- a/k6.js +++ /dev/null @@ -1,18 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -export let options = { - stages: [ - { duration: '1m', target: 100 }, // Ramp-up to 100 RPS over 1 minute - { duration: '10m', target: 100 }, // Stay at 100 RPS for 10 minutes - { duration: '1m', target: 0 }, // Ramp-down to 0 RPS - ], - thresholds: { - http_req_duration: ['p(95)<500'], // 95% of requests should be below 500ms - }, -}; - -export default function () { - http.get('http://localhost:3000/patients'); - sleep(1 / 100); // Make 100 requests per second -} diff --git a/main.go b/main.go index bbb9390..426f26b 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,8 @@ import ( func main() { f := fiber.New() - f.Static("/js", "./js") + + f.Static("/public", "./assets/dist") f.Use(func(ctx *fiber.Ctx) error { if ctx.Cookies("mhtml-session") != "" { diff --git a/pages/base/root.go b/pages/base/root.go index dd02071..6efeb4c 100644 --- a/pages/base/root.go +++ b/pages/base/root.go @@ -8,7 +8,7 @@ import ( func RootPage(children ...h.Renderable) h.Renderable { return h.Html( - h.HxExtension("path-deps"), + h.HxExtension("path-deps, response-targets, mutation-error"), h.Head( h.Script("https://cdn.tailwindcss.com"), h.Script("/js/dist/mhtml.js"), diff --git a/pages/index.go b/pages/index.go index 557d6af..fb2014e 100644 --- a/pages/index.go +++ b/pages/index.go @@ -3,9 +3,15 @@ package pages import ( "github.com/gofiber/fiber/v2" "mhtml/h" - "mhtml/pages/base" ) func IndexPage(c *fiber.Ctx) *h.Page { - return h.NewPage(base.RootPage(h.P("this is cool"))) + return h.NewPage(h.Html( + h.HxExtension("path-deps, response-targets, mutation-error"), + h.Head( + h.Script("https://cdn.tailwindcss.com"), + h.Script("/js/dist/mhtml.js"), + ), + h.Body(), + )) } diff --git a/pages/news.$id.go b/pages/news.$id.go index 60acbe2..650716c 100644 --- a/pages/news.$id.go +++ b/pages/news.$id.go @@ -4,11 +4,9 @@ import ( "fmt" "github.com/gofiber/fiber/v2" "mhtml/h" - "time" ) func Test(ctx *fiber.Ctx) *h.Page { - time.Sleep(time.Second * 1) text := fmt.Sprintf("News ID: %s", ctx.Params("id")) return h.NewPage( h.Div(h.Text(text)), diff --git a/partials/patient/create.go b/partials/patient/create.go index c5e6dc7..9a6f4f4 100644 --- a/partials/patient/create.go +++ b/partials/patient/create.go @@ -2,11 +2,9 @@ package patient import ( "github.com/gofiber/fiber/v2" - "github.com/google/uuid" - "mhtml/database" + "mhtml/features/patient" "mhtml/h" "mhtml/partials/sheet" - "time" ) func Create(ctx *fiber.Ctx) *h.Partial { @@ -14,13 +12,22 @@ func Create(ctx *fiber.Ctx) *h.Partial { reason := ctx.FormValue("reason-for-visit") location := ctx.FormValue("location-name") - database.HSet("patients", uuid.New().String(), Patient{ - Name: name, - ReasonForVisit: reason, - AppointmentDate: time.Now(), - LocationName: location, + err := patient.NewService(ctx).Create(patient.CreatePatientRequest{ + Name: name, + ReasonForVisit: reason, + LocationName: location, }) + if err != nil { + ctx.Status(500) + return h.NewPartialWithHeaders(h.NewHeaders(""), + h.Div( + h.Text("Error creating patient"), + h.Class("text-red-500"), + ), + ) + } + headers := h.CombineHeaders(h.PushQsHeader(ctx, "adding", ""), &map[string]string{ "HX-Trigger": "patient-added", }) diff --git a/partials/patient/patient.go b/partials/patient/patient.go index e87c42c..5570f82 100644 --- a/partials/patient/patient.go +++ b/partials/patient/patient.go @@ -2,23 +2,15 @@ package patient import ( "github.com/gofiber/fiber/v2" - "mhtml/database" + "mhtml/features/patient" "mhtml/h" "mhtml/partials/sheet" "mhtml/ui" "strings" - "time" ) -type Patient struct { - Name string - ReasonForVisit string - AppointmentDate time.Time - LocationName string -} - func List(ctx *fiber.Ctx) *h.Partial { - patients, err := database.HList[Patient]("patients") + patients, err := patient.NewService(ctx).List() if err != nil { return h.NewPartial(h.Div( @@ -35,7 +27,6 @@ func List(ctx *fiber.Ctx) *h.Partial { } return h.NewPartial(h.Div( - h.HxExtension("debug"), h.Class("mt-8"), h.Id("patient-list"), h.List(patients, Row), @@ -92,7 +83,8 @@ func ValidateForm(ctx *fiber.Ctx) *h.Partial { func addPatientForm() h.Renderable { return h.Form( - h.TriggerChildren(), + h.HxExtension("debug, trigger-children"), + h.Attribute("hx-target-5*", "#submit-error"), h.Post(h.GetPartialPath(Create)), h.Class("flex flex-col gap-2"), ui.Input(ui.InputProps{ @@ -124,10 +116,14 @@ func addPatientForm() h.Renderable { Class: "rounded p-2", Type: "submit", }), + h.Div( + h.Id("submit-error"), + h.Class("text-red-500"), + ), ) } -func Row(patient *Patient, index int) h.Renderable { +func Row(patient *patient.Patient, index int) h.Renderable { return h.Div( h.Class("flex flex-col gap-2 rounded p-4", h.Ternary(index%2 == 0, "bg-red-100", "")), h.Pf("Name: %s", patient.Name), diff --git a/ui/button.go b/ui/button.go index 296d943..399ff2d 100644 --- a/ui/button.go +++ b/ui/button.go @@ -29,6 +29,23 @@ func Button(props ButtonProps) h.Renderable { text := h.Text(props.Text) + lifecycle := h.NewLifeCycle(). + BeforeRequest( + h.AddAttribute("disabled", "true"), + h.SetText("Loading..."), + h.AddClass("bg-gray-400"), + ). + AfterRequest( + h.RemoveAttribute("disabled"), + h.RemoveClass("bg-gray-400"), + h.SetText(props.Text), + ). + OnMutationError( + h.SetText("failed"), + h.AddClass("bg-red-400"), + h.RemoveAttribute("disabled"), + ) + button := h.Button( h.If(props.Id != "", h.Id(props.Id)), h.If(props.Children != nil, h.Children(props.Children...)), @@ -36,10 +53,8 @@ func Button(props ButtonProps) h.Renderable { h.Class("flex gap-1 items-center border p-4 rounded cursor-hover", props.Class), h.If(props.Get != "", h.Get(props.Get)), h.If(props.Target != "", h.Target(props.Target)), - //h.Attribute("hx-indicator", "#spinner"), h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")), - h.BeforeRequestSetText("Loading..."), - h.AfterRequestSetText(props.Text), + lifecycle, text, )