Yōten: A social tracker for your language learning journey built on the atproto.

refactor(static/htmx): simplify and update htmx toast code

This now brings in a tool to minify js files.

brookjeynes.dev ed17ed61 bf35dd05

verified
+210 -10
+9 -1
Dockerfile
··· 9 9 chmod +x tailwindcss-linux-x64 && \ 10 10 mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss 11 11 12 + # Install the minify CLI tool 13 + RUN curl -L -o minify.tar.gz https://github.com/tdewolff/minify/releases/download/v2.24.0/minify_linux_amd64.tar.gz && \ 14 + tar -xzf minify.tar.gz && \ 15 + mv minify /usr/local/bin/minify && \ 16 + rm minify.tar.gz 17 + 12 18 WORKDIR /usr/src/app 13 19 14 20 COPY go.mod go.sum ./ ··· 21 27 # Create the directory structure for static assets 22 28 RUN mkdir -p ./static/files 23 29 30 + # Minify existing JS files 31 + RUN minify ./static/*.js -o ./static/files/ 32 + 24 33 # Download frontend libraries into the static folder 25 34 RUN curl -sLo ./static/files/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js 26 35 RUN curl -sLo ./static/files/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js 27 36 RUN curl -sLo ./static/files/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js 28 - RUN curl -sLo ./static/files/htmx-toaster.min.js https://unpkg.com/htmx-toaster@0.0.20/dist/htmx-toaster.min.js 29 37 30 38 RUN tailwindcss -i ./input.css -o ./static/files/style.css --minify 31 39
+6
docs/hacking.md
··· 3 3 ## Required tools 4 4 - [tailwind-cli](https://tailwindcss.com/docs/installation/tailwind-cli) 5 5 - [templ](https://templ.guide/quick-start/installation) 6 + - [minify](https://github.com/tdewolff/minify) 6 7 7 8 ## Running yōten 8 9 ··· 41 42 ```bash 42 43 tailwindcss -i ./input.css -o ./static/files/style.css 43 44 ``` 45 + 46 + If you modified the js files, you will need to regenerate the minified versions: 47 + ```bash 48 + minify static/*.js -o static/files/ 49 + ```
+4
input.css
··· 31 31 --color-blue: hsl(195, 50%, 77%); 32 32 --color-blue-surface: hsla(195, 50%, 77%, 0.3); 33 33 --color-blue-text: hsl(195, 50%, 19%); 34 + 35 + --color-toast-success: hsl(109, 50%, 77%); 36 + --color-toast-info: hsl(195, 50%, 77%); 37 + --color-toast-error: hsl(0, 91%, 77%); 34 38 } 35 39 36 40 @utility card {
-9
internal/server/htmx/htmx.go
··· 5 5 "net/http" 6 6 ) 7 7 8 - func HxOobUpdate(w http.ResponseWriter, id, msg string) { 9 - html := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, msg) 10 - 11 - w.Header().Set("Content-Type", "text/html") 12 - w.Header().Set("HX-Reswap", "none") 13 - w.WriteHeader(http.StatusOK) 14 - w.Write([]byte(html)) 15 - } 16 - 17 8 func HxError(w http.ResponseWriter, status int, msg string) { 18 9 w.Header().Set("HX-Trigger", fmt.Sprintf(`{"HXToast":{"type":"error","body":"%s"}}`, msg)) 19 10 w.WriteHeader(status)
+1
internal/server/views/layouts/base.templ
··· 14 14 <link rel="stylesheet" href="/static/style.css" type="text/css"/> 15 15 </head> 16 16 <body class="flex flex-col min-h-screen bg-bg"> 17 + <htmx-toaster max-toasts="4" duration="7000"></htmx-toaster> 17 18 <main class="flex-1 pb-8 text-text"> 18 19 { children... } 19 20 </main>
+190
static/htmx-toaster.min.js
··· 1 + // Zero-Clause BSD 2 + // ============= 3 + // 4 + // Permission to use, copy, modify, and/or distribute this software for 5 + // any purpose with or without fee is hereby granted. 6 + // 7 + // THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 8 + // WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 9 + // OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 10 + // FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 11 + // DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 12 + // AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 13 + // OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 + // 15 + // Original code available here: https://github.com/terziev1/htmx-toaster 16 + // Modified by Brook Jeynes 17 + 18 + const template = document.createElement("template"); 19 + template.innerHTML = ` 20 + <style> 21 + .toaster { 22 + display: flex; 23 + flex-direction: column; 24 + gap: 12px; 25 + position: fixed; 26 + top: 2rem; 27 + right: 2rem; 28 + z-index: 2147483647; 29 + user-select: none; 30 + pointer-events: none; 31 + align-items: flex-end; 32 + } 33 + 34 + .toast { 35 + position: relative; 36 + width: 100%; 37 + max-width: 350px; 38 + background-color: var(--color-bg-light); 39 + color: var(--color-text); 40 + border: 1px solid var(--color-bg-dark); 41 + border-radius: 8px; 42 + border-left: 4px solid var(--color-bg-dark); 43 + padding: 16px; 44 + padding-right: 32px; 45 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07); 46 + animation: slideIn 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) forwards; 47 + pointer-events: auto; 48 + display: flex; 49 + align-items: center; 50 + } 51 + 52 + .toast.success { border-left-color: var(--color-toast-success); } 53 + .toast.info { border-left-color: var(--color-toast-info); } 54 + .toast.error { border-left-color: var(--color-toast-error); } 55 + 56 + .toast p { 57 + margin: 0; 58 + font-family: ui-sans-serif, system-ui, sans-serif; 59 + line-height: 1.5; 60 + } 61 + 62 + .close-btn { 63 + position: absolute; 64 + top: 8px; 65 + right: 8px; 66 + padding: 4px; 67 + background: transparent; 68 + border: none; 69 + cursor: pointer; 70 + font-size: 1.4em; 71 + line-height: 1; 72 + color: #9CA3AF; 73 + transition: color 0.2s ease; 74 + } 75 + .close-btn:hover { 76 + color: #374151; 77 + } 78 + 79 + .fade-out { animation: fadeOut 0.3s forwards; } 80 + 81 + @keyframes slideIn { 82 + from { transform: translateX(20px) scale(0.95); opacity: 0; } 83 + to { transform: translateX(0) scale(1); opacity: 1; } 84 + } 85 + @keyframes fadeOut { 86 + from { opacity: 1; transform: scale(1); } 87 + to { opacity: 0; transform: scale(0.95); } 88 + } 89 + @media (min-width: 640px) { 90 + .toaster { 91 + top: 3rem; 92 + right: 3rem; 93 + } 94 + } 95 + </style> 96 + <div class="toaster" role="status" aria-live="polite"></div> 97 + `; 98 + 99 + class ToasterComponent extends HTMLElement { 100 + constructor() { 101 + super(); 102 + 103 + this.attachShadow({ mode: "open" }); 104 + this.shadowRoot.appendChild(template.content.cloneNode(true)); 105 + 106 + this.toasterContainer = this.shadowRoot.querySelector(".toaster"); 107 + this.toasts = []; 108 + } 109 + 110 + connectedCallback() { 111 + this.duration = parseInt(this.getAttribute("duration"), 10) || 5000; 112 + this.maxToasts = parseInt(this.getAttribute("max-toasts"), 10) || 3; 113 + 114 + this._initializeEventListeners(); 115 + } 116 + 117 + disconnectedCallback() { 118 + document.body.removeEventListener("HXToast", this._handleHXToastEvent); 119 + document.body.removeEventListener("htmx:afterRequest", this._handleHtmxAfterRequest); 120 + } 121 + 122 + _initializeEventListeners() { 123 + document.body.addEventListener("HXToast", this._handleHXToastEvent); 124 + document.body.addEventListener("htmx:afterRequest", this._handleHtmxAfterRequest); 125 + } 126 + 127 + _handleHXToastEvent = (event) => { 128 + const { body, type = "default" } = event.detail; 129 + if (body) this.addToast(body, type); 130 + } 131 + 132 + _handleHtmxAfterRequest = (event) => { 133 + const body = event.detail.xhr.getResponseHeader("HXToaster-Body"); 134 + const type = event.detail.xhr.getResponseHeader("HXToaster-Type") || "default"; 135 + if (body) this.addToast(body, type); 136 + } 137 + 138 + addToast(body, type = "default") { 139 + const id = `toast-${crypto.randomUUID()}`; 140 + 141 + if (this.toasts.length >= this.maxToasts) { 142 + const oldestToast = this.toasts[this.toasts.length - 1]; 143 + this.removeToast(oldestToast.id); 144 + } 145 + 146 + const newToast = { id, body, type }; 147 + this.toasts.unshift(newToast); 148 + this._renderToast(newToast); 149 + 150 + return id; 151 + } 152 + 153 + removeToast(id) { 154 + const index = this.toasts.findIndex((toast) => toast.id === id); 155 + if (index === -1) return; 156 + 157 + this.toasts.splice(index, 1); 158 + 159 + const toastElement = this.shadowRoot.querySelector(`#${id}`); 160 + if (toastElement) { 161 + toastElement.classList.add("fade-out"); 162 + toastElement.addEventListener("animationend", () => toastElement.remove(), { once: true }); 163 + } 164 + } 165 + 166 + _renderToast({ id, body, type }) { 167 + const toastElement = document.createElement("div"); 168 + toastElement.id = id; 169 + toastElement.className = `toast ${type}`; 170 + 171 + const p = document.createElement("p"); 172 + p.textContent = body; 173 + 174 + const closeButton = document.createElement("button"); 175 + closeButton.className = "close-btn"; 176 + closeButton.innerHTML = "&#10005;"; 177 + closeButton.addEventListener("click", () => this.removeToast(id)); 178 + 179 + toastElement.appendChild(p); 180 + toastElement.appendChild(closeButton); 181 + 182 + this.toasterContainer.prepend(toastElement); 183 + 184 + setTimeout(() => this.removeToast(id), this.duration); 185 + } 186 + } 187 + 188 + if (!customElements.get("htmx-toaster")) { 189 + customElements.define("htmx-toaster", ToasterComponent); 190 + }