lyrics

lyrics
git clone git://brookjeynes.dev/bjeynes/lyrics.git
Log | Files | Refs

commit b3995b782c0e91799d48bae881f2b300f17083a4
parent d3611500ad265f8db9344bf21815d1c1e026a6fe
Author: brookjeynes <me@brookjeynes.dev>
Date:   Tue, 28 Apr 2026 21:13:40 +1000

feat: add toast

Signed-off-by: brookjeynes <me@brookjeynes.dev>

Diffstat:
Minternal/server/router.go | 1+
Ainternal/server/static.go | 43+++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/toast/toast.css | 43+++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/toast/toast.go | 39+++++++++++++++++++++++++++++++++++++++
Ainternal/server/toast/toast.js | 33+++++++++++++++++++++++++++++++++
Minternal/server/ui/layouts/base/base.templ | 9+++++++++
Astatic/static.go | 18++++++++++++++++++
7 files changed, 186 insertions(+), 0 deletions(-)

diff --git a/internal/server/router.go b/internal/server/router.go @@ -11,5 +11,6 @@ func (ss *ServerState) Router() http.Handler { router.Get("/", ss.Index) + router.Handle("/static/*", ss.Static()) return router } diff --git a/internal/server/static.go b/internal/server/static.go @@ -0,0 +1,43 @@ +package server + +import ( + "log" + "lyrics/static" + "net/http" +) + +func (ss *ServerState) Static() http.Handler { + var finalHandler http.Handler + + if ss.config.Core.Dev { + fileSystem := http.Dir("static/files") + fileServer := http.FileServer(fileSystem) + finalHandler = NoCache(http.StripPrefix("/static/", fileServer)) + } else { + fs, err := static.FS() + if err != nil { + log.Fatal("failed to create embedded static file system:", err) + } + fileSystem := fs + fileServer := http.FileServer(fileSystem) + finalHandler = Cache(http.StripPrefix("/static/", fileServer)) + } + + return finalHandler +} + +func Cache(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=3600") + h.ServeHTTP(w, r) + }) +} + +func NoCache(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + h.ServeHTTP(w, r) + }) +} diff --git a/internal/server/toast/toast.css b/internal/server/toast/toast.css @@ -0,0 +1,43 @@ +#toast-container { + position: fixed; + right: 1rem; + top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toast { + background-color: white; + border: 1px solid black; + padding: 0.2rem 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + opacity: 0; + transform: translateY(8px); + animation: toast-in 0.2s ease-out forwards; +} + +.toast button { + background: none; + border: none; + cursor: pointer; + padding: 0; + font: inherit; + line-height: 1; + flex-shrink: 0; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} diff --git a/internal/server/toast/toast.go b/internal/server/toast/toast.go @@ -0,0 +1,39 @@ +package toast + +import "encoding/json" + +type ToastType = string + +const ( + ToastTypeError ToastType = "error" + ToastTypeSuccess ToastType = "success" +) + +type Toast struct { + Type ToastType + Body string +} + +func Error(body string) Toast { + return Toast{ + Type: ToastTypeError, + Body: body, + } +} + +func Success(body string) Toast { + return Toast{ + Type: ToastTypeSuccess, + Body: body, + } +} + +func (t *Toast) Marshal() ([]byte, error) { + trigger := map[string]any{ + "HXToast": map[string]string{ + "type": t.Type, + "body": t.Body, + }, + } + return json.Marshal(trigger) +} diff --git a/internal/server/toast/toast.js b/internal/server/toast/toast.js @@ -0,0 +1,33 @@ +class Toast { + constructor(type, body) { + this.type = type; + this.body = body; + } + + make() { + const isError = this.type === "error"; + + const container = document.createElement("div"); + container.classList.add("toast", `toast-${this.type}`); + container.setAttribute("role", isError ? "alert" : "status"); + + const message = document.createElement("span"); + message.textContent = this.body; + + const dismiss = document.createElement("button"); + dismiss.setAttribute("type", "button"); + dismiss.setAttribute("aria-label", "Dismiss"); + dismiss.textContent = "x"; + dismiss.addEventListener("click", () => container.remove()); + + container.appendChild(message); + container.appendChild(dismiss); + + return container; + } + + show() { + const toastContainer = document.querySelector("#toast-container"); + toastContainer.appendChild(this.make()); + } +} diff --git a/internal/server/ui/layouts/base/base.templ b/internal/server/ui/layouts/base/base.templ @@ -6,12 +6,21 @@ templ Base(params BaseParams) { <head> <meta charset="UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <script src="/static/js/toast.js" defer></script> + <link rel="stylesheet" href="/static/toast.css" type="text/css"/> <title>{ params.Title }</title> </head> <body> <main> { children... } </main> + <section id="toast-container"></section> </body> + <script> + document.body.addEventListener("HXToast", (evt) => { + const toast = new Toast(evt.detail.type, evt.detail.body); + toast.show(); + }); + </script> </html> } diff --git a/static/static.go b/static/static.go @@ -0,0 +1,18 @@ +package static + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed files +var StaticFiles embed.FS + +func FS() (http.FileSystem, error) { + subFS, err := fs.Sub(StaticFiles, "files") + if err != nil { + return nil, err + } + return http.FS(subFS), nil +}