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:
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
+}