we now got websockets baby
This commit is contained in:
@@ -3,7 +3,7 @@ testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = ["--htmxlog"]
|
||||
args_bin = ["--dev"]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./cmd/oslstats"
|
||||
delay = 1000
|
||||
@@ -14,7 +14,7 @@ tmp_dir = "tmp"
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "templ"]
|
||||
include_ext = ["go", "templ", "js"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
|
||||
@@ -35,6 +35,12 @@ func setupHttpServer(
|
||||
ignoredPaths := []string{
|
||||
"/static/css/output.css",
|
||||
"/static/favicon.ico",
|
||||
"/static/js/popups.js",
|
||||
"/static/js/theme.js",
|
||||
"/static/vendored/htmx@2.0.8.min.js",
|
||||
"/static/vendored/htmx-ext-ws.min.js",
|
||||
"/static/vendored/alpinejs@3.15.4.min.js",
|
||||
"/ws/notifications",
|
||||
}
|
||||
|
||||
auth, err := setupAuth(cfg.HWSAuth, logger, bun, httpServer, ignoredPaths)
|
||||
@@ -62,7 +68,7 @@ func setupHttpServer(
|
||||
return nil, errors.Wrap(err, "addRoutes")
|
||||
}
|
||||
|
||||
err = addMiddleware(httpServer, auth, cfg.Flags)
|
||||
err = addMiddleware(httpServer, auth, cfg)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "addMiddleware")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/hwsauth"
|
||||
@@ -17,12 +18,12 @@ import (
|
||||
func addMiddleware(
|
||||
server *hws.Server,
|
||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||
flags *config.Flags,
|
||||
cfg *config.Config,
|
||||
) error {
|
||||
|
||||
err := server.AddMiddleware(
|
||||
auth.Authenticate(),
|
||||
htmxLog(flags.HTMXLog),
|
||||
devMode(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server.AddMiddleware")
|
||||
@@ -30,12 +31,20 @@ func addMiddleware(
|
||||
return nil
|
||||
}
|
||||
|
||||
func htmxLog(htmxlog bool) hws.Middleware {
|
||||
func devMode(cfg *config.Config) hws.Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), contexts.HTMXLogKey, htmxlog)
|
||||
req := r.WithContext(ctx)
|
||||
next.ServeHTTP(w, req)
|
||||
if cfg.Flags.DevMode {
|
||||
devInfo := contexts.DevInfo{
|
||||
WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10),
|
||||
HTMXLog: true,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
|
||||
req := r.WithContext(ctx)
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func addRoutes(
|
||||
server *hws.Server,
|
||||
s *hws.Server,
|
||||
staticFS *http.FileSystem,
|
||||
cfg *config.Config,
|
||||
conn *bun.DB,
|
||||
@@ -25,41 +25,42 @@ func addRoutes(
|
||||
discordAPI *discord.APIClient,
|
||||
) error {
|
||||
// Create the routes
|
||||
routes := []hws.Route{
|
||||
pageroutes := []hws.Route{
|
||||
{
|
||||
Path: "/static/",
|
||||
Method: hws.MethodGET,
|
||||
Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, server)),
|
||||
Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, s)),
|
||||
},
|
||||
{
|
||||
Path: "/",
|
||||
Method: hws.MethodGET,
|
||||
Handler: handlers.Index(server),
|
||||
Handler: handlers.Index(s),
|
||||
},
|
||||
{
|
||||
Path: "/login",
|
||||
Method: hws.MethodGET,
|
||||
Handler: auth.LogoutReq(handlers.Login(server, cfg, store, discordAPI)),
|
||||
Handler: auth.LogoutReq(handlers.Login(s, cfg, store, discordAPI)),
|
||||
},
|
||||
{
|
||||
Path: "/auth/callback",
|
||||
Method: hws.MethodGET,
|
||||
Handler: auth.LogoutReq(handlers.Callback(server, auth, conn, cfg, store, discordAPI)),
|
||||
Handler: auth.LogoutReq(handlers.Callback(s, auth, conn, cfg, store, discordAPI)),
|
||||
},
|
||||
{
|
||||
Path: "/register",
|
||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||
Handler: auth.LogoutReq(handlers.Register(server, auth, conn, cfg, store)),
|
||||
Handler: auth.LogoutReq(handlers.Register(s, auth, conn, cfg, store)),
|
||||
},
|
||||
{
|
||||
Path: "/logout",
|
||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||
Handler: auth.LoginReq(handlers.Logout(server, auth, conn, discordAPI)),
|
||||
Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)),
|
||||
},
|
||||
{
|
||||
Path: "/test",
|
||||
Path: "/notification-tester",
|
||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||
Handler: handlers.Test(server),
|
||||
Handler: handlers.NotifyTester(s),
|
||||
// TODO: add login protection
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,12 +68,23 @@ func addRoutes(
|
||||
{
|
||||
Path: "/htmx/isusernameunique",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: handlers.IsUsernameUnique(server, conn, cfg, store),
|
||||
Handler: handlers.IsUsernameUnique(s, conn, cfg, store),
|
||||
},
|
||||
}
|
||||
|
||||
wsRoutes := []hws.Route{
|
||||
{
|
||||
Path: "/ws/notifications",
|
||||
Method: hws.MethodGET,
|
||||
Handler: handlers.NotificationWS(s, cfg),
|
||||
},
|
||||
}
|
||||
|
||||
routes := append(pageroutes, htmxRoutes...)
|
||||
routes = append(routes, wsRoutes...)
|
||||
|
||||
// Register the routes with the server
|
||||
err := server.AddRoutes(append(routes, htmxRoutes...)...)
|
||||
err := s.AddRoutes(routes...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server.AddRoutes")
|
||||
}
|
||||
|
||||
@@ -73,8 +73,9 @@ func run(ctx context.Context, w io.Writer, cfg *config.Config) error {
|
||||
wg.Go(func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx := context.Background()
|
||||
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 60*time.Second)
|
||||
defer cancel()
|
||||
logger.Info().Msg("Shut down requested, waiting 60 seconds...")
|
||||
err := httpServer.Shutdown(shutdownCtx)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Graceful shutdown failed")
|
||||
|
||||
6
go.mod
6
go.mod
@@ -6,9 +6,11 @@ require (
|
||||
git.haelnorr.com/h/golib/env v0.9.1
|
||||
git.haelnorr.com/h/golib/ezconf v0.1.1
|
||||
git.haelnorr.com/h/golib/hlog v0.10.4
|
||||
git.haelnorr.com/h/golib/hws v0.3.2
|
||||
git.haelnorr.com/h/golib/hws v0.4.0
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.2
|
||||
git.haelnorr.com/h/golib/notify v0.1.0
|
||||
github.com/a-h/templ v0.3.977
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/uptrace/bun v1.2.16
|
||||
@@ -18,7 +20,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
12
go.sum
12
go.sum
@@ -6,18 +6,22 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI
|
||||
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
|
||||
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
|
||||
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
||||
git.haelnorr.com/h/golib/hws v0.3.2 h1:OSwCwVxDerermyuoxrAYgt+i1zzwF/0Baoy8zmCxtuQ=
|
||||
git.haelnorr.com/h/golib/hws v0.3.2/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
|
||||
git.haelnorr.com/h/golib/hws v0.4.0 h1:T2JfRz4zpgsNXj0Vyfzxdf/60Tee/7H30osFmr5jDh0=
|
||||
git.haelnorr.com/h/golib/hws v0.4.0/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.2 h1:K4McXMEHtI5o4fAL3AZrmaMkwORNqSTV3MM6BExNKag=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.5.2/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
|
||||
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
||||
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
||||
git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10=
|
||||
git.haelnorr.com/h/golib/notify v0.1.0/go.mod h1:ARqaRmCYb8LMURhDM75sG+qX+YpqXmUVeAtacwjHjBc=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -71,8 +75,8 @@ go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwEx
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -12,7 +12,7 @@ type Flags struct {
|
||||
ShowEnv bool
|
||||
GenEnv string
|
||||
EnvFile string
|
||||
HTMXLog bool
|
||||
DevMode bool
|
||||
|
||||
// Database reset (destructive)
|
||||
ResetDB bool
|
||||
@@ -34,7 +34,7 @@ func SetupFlags() (*Flags, error) {
|
||||
showEnv := flag.Bool("showenv", false, "Print all environment variable values and their documentation")
|
||||
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
|
||||
envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
|
||||
htmxlog := flag.Bool("htmxlog", false, "Run the server with all HTMX events logged")
|
||||
devMode := flag.Bool("dev", false, "Run the server in dev mode")
|
||||
|
||||
// Database reset (destructive)
|
||||
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
|
||||
@@ -78,7 +78,7 @@ func SetupFlags() (*Flags, error) {
|
||||
ShowEnv: *showEnv,
|
||||
GenEnv: *genEnv,
|
||||
EnvFile: *envfile,
|
||||
HTMXLog: *htmxlog,
|
||||
DevMode: *devMode,
|
||||
ResetDB: *resetDB,
|
||||
MigrateUp: *migrateUp,
|
||||
MigrateRollback: *migrateRollback,
|
||||
|
||||
120
internal/handlers/notifswebsocket.go
Normal file
120
internal/handlers/notifswebsocket.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/cookies"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/view/component/popup"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func NotificationWS(
|
||||
s *hws.Server,
|
||||
cfg *config.Config,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Upgrade") != "websocket" {
|
||||
throwNotFound(s, w, r, r.URL.Path)
|
||||
return
|
||||
}
|
||||
nc, err := setupClient(s, w, r)
|
||||
if err != nil {
|
||||
s.LogError(hws.HWSError{
|
||||
Message: "Failed to get notification client",
|
||||
Error: err,
|
||||
Level: hws.ErrorERROR,
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{cfg.HWSAuth.TrustedHost},
|
||||
})
|
||||
if err != nil {
|
||||
s.LogError(hws.HWSError{
|
||||
Message: "Failed to open websocket",
|
||||
Error: err,
|
||||
Level: hws.ErrorERROR,
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer ws.CloseNow()
|
||||
ctx := ws.CloseRead(r.Context())
|
||||
err = notifyLoop(ctx, nc, ws)
|
||||
if err != nil {
|
||||
s.LogError(hws.HWSError{
|
||||
Message: "Notification error",
|
||||
Error: err,
|
||||
Level: hws.ErrorERROR,
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func setupClient(s *hws.Server, w http.ResponseWriter, r *http.Request) (*hws.Client, error) {
|
||||
user := db.CurrentUser(r.Context())
|
||||
altID := ""
|
||||
if user != nil {
|
||||
altID = strconv.Itoa(user.ID)
|
||||
}
|
||||
subCookie, err := r.Cookie("ws_sub_id")
|
||||
subID := ""
|
||||
if err == nil {
|
||||
subID = subCookie.Value
|
||||
}
|
||||
nc, err := s.GetClient(subID, altID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "s.GetClient")
|
||||
}
|
||||
cookies.SetCookie(w, "ws_sub_id", "/", nc.ID(), 0)
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
func notifyLoop(ctx context.Context, c *hws.Client, ws *websocket.Conn) error {
|
||||
notifs, stop := c.Listen()
|
||||
defer close(stop)
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case nt, ok := <-notifs:
|
||||
count++
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
w, err := ws.Writer(ctx, websocket.MessageText)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "ws.Writer")
|
||||
}
|
||||
switch nt.Level {
|
||||
case hws.LevelShutdown:
|
||||
err = popup.Toast(nt, count, 30000).Render(ctx, w)
|
||||
case notify.LevelWarn:
|
||||
err = popup.Toast(nt, count, 10000).Render(ctx, w)
|
||||
case notify.LevelError:
|
||||
// do error modal
|
||||
default:
|
||||
err = popup.Toast(nt, count, 6000).Render(ctx, w)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "popup.Toast")
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "w.Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,36 +7,45 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/notify"
|
||||
)
|
||||
|
||||
// Handles responses to the / path. Also serves a 404 Page for paths that
|
||||
// don't have explicit handlers
|
||||
func Test(server *hws.Server) http.Handler {
|
||||
func NotifyTester(server *hws.Server) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
page.Test().Render(r.Context(), w)
|
||||
} else {
|
||||
r.ParseForm()
|
||||
notifytype := r.Form.Get("type")
|
||||
target := r.Form.Get("target")
|
||||
title := r.Form.Get("title")
|
||||
level := map[string]notify.Level{
|
||||
"info": notify.LevelInfo,
|
||||
"success": notify.LevelSuccess,
|
||||
"warn": notify.LevelWarn,
|
||||
"error": notify.LevelError,
|
||||
}[r.Form.Get("type")]
|
||||
error := errors.New("This is a stack trace. No really i swear. Just pretend ok? Thanks")
|
||||
message := r.Form.Get("message")
|
||||
switch notifytype {
|
||||
case "error":
|
||||
err := errors.New(message)
|
||||
notifyInternalServiceError(server, w, title, err)
|
||||
return
|
||||
case "warn":
|
||||
notifyWarning(w, title, message)
|
||||
return
|
||||
case "info":
|
||||
notifyInfo(w, title, message)
|
||||
return
|
||||
case "success":
|
||||
notifySuccess(w, title, message)
|
||||
return
|
||||
nt := notify.Notification{
|
||||
Target: notify.Target(target),
|
||||
Title: title,
|
||||
Message: message,
|
||||
Level: level,
|
||||
Details: error.Error(),
|
||||
}
|
||||
if target == "" {
|
||||
server.NotifyAll(nt)
|
||||
} else {
|
||||
server.NotifySub(nt)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func notifyInfoWS() {
|
||||
|
||||
}
|
||||
|
||||
110
internal/view/component/popup/toast.templ
Normal file
110
internal/view/component/popup/toast.templ
Normal file
@@ -0,0 +1,110 @@
|
||||
package popup
|
||||
|
||||
import "fmt"
|
||||
import "git.haelnorr.com/h/golib/notify"
|
||||
import "git.haelnorr.com/h/golib/hws"
|
||||
|
||||
// ToastNotificationWS creates a toast notification sent via WebSocket with OOB swap
|
||||
// Backend should pass: notifType ("success"|"warning"|"info"), title, message
|
||||
templ Toast(nt notify.Notification, id, duration int) {
|
||||
{{
|
||||
// Determine classes server-side based on notifType
|
||||
color := map[notify.Level]string{
|
||||
notify.LevelSuccess: "green",
|
||||
notify.LevelWarn: "yellow",
|
||||
notify.LevelInfo: "blue",
|
||||
hws.LevelShutdown: "yellow",
|
||||
}
|
||||
containerClass := fmt.Sprintf("bg-dark-%s border-2 border-%s", color[nt.Level], color[nt.Level])
|
||||
textClass := fmt.Sprintf("text-%s", color[nt.Level])
|
||||
progressClass := fmt.Sprintf("bg-%s", color[nt.Level])
|
||||
}}
|
||||
<div
|
||||
id="toast-ws-container"
|
||||
hx-swap-oob="beforeend:#toast-ws-container"
|
||||
>
|
||||
<div
|
||||
id={ fmt.Sprintf("ws-toast-%d", id) }
|
||||
class={ fmt.Sprintf("pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full mb-2 %s", containerClass) }
|
||||
role="alert"
|
||||
x-data={ templ.JSFuncCall("progressBar", fmt.Sprintf("ws-toast-%d", id), duration).CallInline }
|
||||
@mouseenter="paused = true"
|
||||
>
|
||||
<!-- @mouseleave="paused = false" -->
|
||||
<!-- Toast Content -->
|
||||
<div class="p-4 max-w-80">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<!-- Icon + Content -->
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<!-- Icon (rendered server-side based on notifType) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class={ fmt.Sprintf("size-5 shrink-0 %s", textClass) }
|
||||
>
|
||||
switch nt.Level {
|
||||
case notify.LevelSuccess:
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
case notify.LevelWarn:
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
default:
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
}
|
||||
</svg>
|
||||
<!-- Text Content (rendered server-side) -->
|
||||
<div class="flex-1 min-w-0 max-w-48 text-wrap">
|
||||
<p class={ fmt.Sprintf("font-semibold %s", textClass) }>
|
||||
{ nt.Title }
|
||||
</p>
|
||||
<p class="text-sm text-subtext0 mt-1 text-wrap">
|
||||
{ nt.Message }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
onclick="this.closest('[id^=ws-toast-]').remove()"
|
||||
class="text-subtext0 hover:text-text transition shrink-0 hover:cursor-pointer"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar (vanilla JS manages width) -->
|
||||
<div class="h-1 bg-surface0">
|
||||
<div
|
||||
id={ fmt.Sprintf("ws-toast-%d-progress", id) }
|
||||
class={ fmt.Sprintf("h-full %s", progressClass) }
|
||||
style="width: 0"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package popup
|
||||
|
||||
// ToastContainer displays stacked toast notifications (success, warning, info)
|
||||
// Supports both regular toasts (AlpineJS) and WebSocket toasts (HTMX OOB swaps)
|
||||
templ ToastContainer() {
|
||||
<div class="fixed top-20 right-5 z-40 flex flex-col gap-3 max-w-sm pointer-events-none">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
@ToastNotification()
|
||||
</template>
|
||||
<div id="toast-ws-container"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package popup
|
||||
|
||||
// ToastNotification displays an individual toast notification
|
||||
templ ToastNotification() {
|
||||
<div
|
||||
class="pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full"
|
||||
:class="{
|
||||
'bg-dark-green border-2 border-green': toast.type === 'success',
|
||||
'bg-dark-yellow border-2 border-yellow': toast.type === 'warning',
|
||||
'bg-dark-blue border-2 border-blue': toast.type === 'info'
|
||||
}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-full"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-full"
|
||||
@mouseenter="toast.paused = true"
|
||||
@mouseleave="toast.paused = false"
|
||||
role="alert"
|
||||
>
|
||||
<!-- Toast Content -->
|
||||
<div class="p-4">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<!-- Icon + Content -->
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<!-- Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-5 flex-shrink-0"
|
||||
:class="{
|
||||
'text-green': toast.type === 'success',
|
||||
'text-yellow': toast.type === 'warning',
|
||||
'text-blue': toast.type === 'info'
|
||||
}"
|
||||
>
|
||||
<!-- Success Icon (checkmark) -->
|
||||
<template x-if="toast.type === 'success'">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</template>
|
||||
<!-- Warning Icon (exclamation) -->
|
||||
<template x-if="toast.type === 'warning'">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</template>
|
||||
<!-- Info Icon (information) -->
|
||||
<template x-if="toast.type === 'info'">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</template>
|
||||
</svg>
|
||||
<!-- Text Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-semibold"
|
||||
:class="{
|
||||
'text-green': toast.type === 'success',
|
||||
'text-yellow': toast.type === 'warning',
|
||||
'text-blue': toast.type === 'info'
|
||||
}"
|
||||
x-text="toast.title"
|
||||
></p>
|
||||
<p class="text-sm text-subtext0 mt-1" x-text="toast.message"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="text-subtext0 hover:text-text transition flex-shrink-0"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-1 bg-surface0">
|
||||
<div
|
||||
class="h-full"
|
||||
:class="{
|
||||
'bg-green': toast.type === 'success',
|
||||
'bg-yellow': toast.type === 'warning',
|
||||
'bg-blue': toast.type === 'info'
|
||||
}"
|
||||
:style="`width: ${toast.progress}%`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -8,13 +8,11 @@ import "git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||
// Global page layout. Includes HTML document settings, header tags
|
||||
// navbar and footer
|
||||
templ Global(title string) {
|
||||
{{ htmxLogging := contexts.HTMXLog(ctx) }}
|
||||
{{ devInfo := contexts.DevMode(ctx) }}
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
x-data="{
|
||||
theme: localStorage.getItem('theme')
|
||||
|| 'system'}"
|
||||
x-data="{ theme: localStorage.getItem('theme') || 'system'}"
|
||||
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
|
||||
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
|
||||
@@ -26,147 +24,23 @@ templ Global(title string) {
|
||||
<title>{ title }</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
|
||||
<link href="/static/css/output.css" rel="stylesheet"/>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||
if htmxLogging {
|
||||
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
||||
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
||||
<script src="/static/js/toasts.js" defer></script>
|
||||
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
|
||||
if devInfo.HTMXLog {
|
||||
<script>
|
||||
htmx.logAll();
|
||||
</script>
|
||||
}
|
||||
<script>
|
||||
const bodyData = {
|
||||
// Error modal (blocking, full-screen) - for 500/503 errors
|
||||
errorModal: {
|
||||
show: false,
|
||||
code: 0,
|
||||
title: '',
|
||||
message: '',
|
||||
details: ''
|
||||
},
|
||||
|
||||
// Toast notifications (non-blocking, stacked) - for success/warning/info
|
||||
toasts: [],
|
||||
toastIdCounter: 0,
|
||||
|
||||
// Handle HTMX beforeSwap event - intercept both errors and successes
|
||||
handleBeforeSwap(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
if (!xhr) return;
|
||||
|
||||
const status = xhr.status;
|
||||
const trigger = xhr.getResponseHeader('HX-Trigger');
|
||||
if (!trigger) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(trigger);
|
||||
|
||||
// Handle 500/503 error modals
|
||||
if ((status === 500 || status === 503) && data.showErrorModal) {
|
||||
this.errorModal = {
|
||||
show: true,
|
||||
code: data.showErrorModal.code,
|
||||
title: data.showErrorModal.title,
|
||||
message: data.showErrorModal.message,
|
||||
details: data.showErrorModal.details
|
||||
};
|
||||
// Prevent swap but allow error logging
|
||||
event.detail.shouldSwap = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle success/warning/info toasts (200-299)
|
||||
if (status >= 200 && status < 300 && data.showNotification) {
|
||||
this.addToast(data.showNotification);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse HX-Trigger:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// Add toast to stack
|
||||
addToast(notification) {
|
||||
const toast = {
|
||||
id: ++this.toastIdCounter,
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
paused: false,
|
||||
progress: 0
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
// Determine timeout based on type
|
||||
const timeout = notification.type === 'warning' ? 5000 : 3000;
|
||||
|
||||
// Start progress animation
|
||||
this.animateToastProgress(toast.id, timeout);
|
||||
},
|
||||
|
||||
// Animate toast progress bar and auto-dismiss
|
||||
animateToastProgress(toastId, duration) {
|
||||
const toast = this.toasts.find(t => t.id === toastId);
|
||||
if (!toast) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
let totalPausedTime = 0;
|
||||
let pauseStartTime = null;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const toast = this.toasts.find(t => t.id === toastId);
|
||||
if (!toast) return; // Toast was manually removed
|
||||
|
||||
if (toast.paused) {
|
||||
// Track when pause started
|
||||
if (pauseStartTime === null) {
|
||||
pauseStartTime = currentTime;
|
||||
}
|
||||
// Keep animating while paused
|
||||
requestAnimationFrame(animate);
|
||||
return;
|
||||
} else {
|
||||
// If we were paused, accumulate the paused time
|
||||
if (pauseStartTime !== null) {
|
||||
totalPausedTime += currentTime - pauseStartTime;
|
||||
pauseStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual elapsed time (excluding paused time)
|
||||
const elapsed = currentTime - startTime - totalPausedTime;
|
||||
toast.progress = Math.min((elapsed / duration) * 100, 100);
|
||||
|
||||
if (elapsed >= duration) {
|
||||
this.removeToast(toastId);
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
// Remove toast from stack
|
||||
removeToast(id) {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
},
|
||||
|
||||
// Close error modal
|
||||
closeErrorModal() {
|
||||
this.errorModal.show = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
|
||||
x-data="bodyData"
|
||||
x-on:htmx:before-swap="handleBeforeSwap($event)"
|
||||
x-on:keydown.escape.window="closeErrorModal()"
|
||||
>
|
||||
@popup.ErrorModal()
|
||||
@popup.ToastContainer()
|
||||
<body
|
||||
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
|
||||
hx-ext="ws"
|
||||
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
|
||||
>
|
||||
<!-- @popup.ErrorModal() -->
|
||||
@popup.ToastContainer()
|
||||
<div
|
||||
id="main-content"
|
||||
class="flex flex-col h-screen justify-between"
|
||||
|
||||
@@ -24,12 +24,25 @@ templ Test() {
|
||||
id="type"
|
||||
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
|
||||
>
|
||||
<option value="error">Error Modal</option>
|
||||
<option value="warn">Warning Toast</option>
|
||||
<option value="info">Info Toast</option>
|
||||
<option value="success">Success Toast</option>
|
||||
<option value="info">Info Toast</option>
|
||||
<option value="warn">Warning Toast</option>
|
||||
<!-- <option value="error">Error Modal</option> -->
|
||||
</select>
|
||||
</div>
|
||||
<!-- Target Input -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="target" class="text-sm font-semibold text-subtext1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="target"
|
||||
id="target"
|
||||
placeholder="Enter target ID"
|
||||
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
|
||||
/>
|
||||
</div>
|
||||
<!-- Title Input -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="title" class="text-sm font-semibold text-subtext1">
|
||||
|
||||
@@ -8,12 +8,17 @@ func (c Key) String() string {
|
||||
return "oslstats context key " + string(c)
|
||||
}
|
||||
|
||||
var HTMXLogKey Key = Key("htmxlog")
|
||||
var DevModeKey Key = Key("devmode")
|
||||
|
||||
func HTMXLog(ctx context.Context) bool {
|
||||
htmxlog, ok := ctx.Value(HTMXLogKey).(bool)
|
||||
func DevMode(ctx context.Context) DevInfo {
|
||||
devmode, ok := ctx.Value(DevModeKey).(DevInfo)
|
||||
if !ok {
|
||||
return false
|
||||
return DevInfo{}
|
||||
}
|
||||
return htmxlog
|
||||
return devmode
|
||||
}
|
||||
|
||||
type DevInfo struct {
|
||||
WebsocketBase string
|
||||
HTMXLog bool
|
||||
}
|
||||
|
||||
@@ -364,6 +364,9 @@
|
||||
.mr-5 {
|
||||
margin-right: calc(var(--spacing) * 5);
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: calc(var(--spacing) * 8);
|
||||
}
|
||||
@@ -454,6 +457,12 @@
|
||||
.max-w-7xl {
|
||||
max-width: var(--container-7xl);
|
||||
}
|
||||
.max-w-48 {
|
||||
max-width: calc(var(--spacing) * 48);
|
||||
}
|
||||
.max-w-80 {
|
||||
max-width: calc(var(--spacing) * 80);
|
||||
}
|
||||
.max-w-100 {
|
||||
max-width: calc(var(--spacing) * 100);
|
||||
}
|
||||
@@ -478,6 +487,9 @@
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -707,6 +719,9 @@
|
||||
.p-4 {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
.p-5 {
|
||||
padding: calc(var(--spacing) * 5);
|
||||
}
|
||||
.p-6 {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
}
|
||||
@@ -807,6 +822,9 @@
|
||||
--tw-tracking: var(--tracking-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.text-wrap {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
const popupData = {
|
||||
// Error modal (blocking, full-screen) - for 500/503 errors
|
||||
errorModal: {
|
||||
show: false,
|
||||
code: 0,
|
||||
title: "",
|
||||
message: "",
|
||||
details: "",
|
||||
},
|
||||
|
||||
// Toast notifications (non-blocking, stacked) - for success/warning/info
|
||||
toasts: [],
|
||||
toastIdCounter: 0,
|
||||
|
||||
// Handle HTMX beforeSwap event - intercept both errors and successes
|
||||
handleBeforeSwap(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
if (!xhr) return;
|
||||
|
||||
const status = xhr.status;
|
||||
const trigger = xhr.getResponseHeader("HX-Trigger");
|
||||
if (!trigger) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(trigger);
|
||||
|
||||
// Handle 500/503 error modals
|
||||
if ((status === 500 || status === 503) && data.showErrorModal) {
|
||||
this.errorModal = {
|
||||
show: true,
|
||||
code: data.showErrorModal.code,
|
||||
title: data.showErrorModal.title,
|
||||
message: data.showErrorModal.message,
|
||||
details: data.showErrorModal.details,
|
||||
};
|
||||
// Prevent swap but allow error logging
|
||||
event.detail.shouldSwap = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle success/warning/info toasts (200-299)
|
||||
if (status >= 200 && status < 300 && data.showNotification) {
|
||||
this.addToast(data.showNotification);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse HX-Trigger:", e);
|
||||
}
|
||||
},
|
||||
|
||||
// Add toast to stack
|
||||
addToast(notification) {
|
||||
const toast = {
|
||||
id: ++this.toastIdCounter,
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
paused: false,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
// Determine timeout based on type
|
||||
const timeout = notification.type === "warning" ? 5000 : 3000;
|
||||
|
||||
// Start progress animation
|
||||
this.animateToastProgress(toast.id, timeout);
|
||||
},
|
||||
|
||||
// Animate toast progress bar and auto-dismiss
|
||||
animateToastProgress(toastId, duration) {
|
||||
const toast = this.toasts.find((t) => t.id === toastId);
|
||||
if (!toast) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
let totalPausedTime = 0;
|
||||
let pauseStartTime = null;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const toast = this.toasts.find((t) => t.id === toastId);
|
||||
if (!toast) return; // Toast was manually removed
|
||||
|
||||
if (toast.paused) {
|
||||
// Track when pause started
|
||||
if (pauseStartTime === null) {
|
||||
pauseStartTime = currentTime;
|
||||
}
|
||||
// Keep animating while paused
|
||||
requestAnimationFrame(animate);
|
||||
return;
|
||||
} else {
|
||||
// If we were paused, accumulate the paused time
|
||||
if (pauseStartTime !== null) {
|
||||
totalPausedTime += currentTime - pauseStartTime;
|
||||
pauseStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual elapsed time (excluding paused time)
|
||||
const elapsed = currentTime - startTime - totalPausedTime;
|
||||
toast.progress = Math.min((elapsed / duration) * 100, 100);
|
||||
|
||||
if (elapsed >= duration) {
|
||||
this.removeToast(toastId);
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
// Remove toast from stack
|
||||
removeToast(id) {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
},
|
||||
|
||||
// Close error modal
|
||||
closeErrorModal() {
|
||||
this.errorModal.show = false;
|
||||
},
|
||||
};
|
||||
|
||||
// Global function for WebSocket toasts to animate progress and auto-dismiss
|
||||
// Pure vanilla JavaScript - no Alpine.js dependency
|
||||
// Called via inline script in ToastNotificationWS template
|
||||
function animateToastProgress(toastId, duration) {
|
||||
const toastEl = document.getElementById(toastId);
|
||||
const progressEl = document.getElementById(toastId + "-progress");
|
||||
|
||||
if (!toastEl || !progressEl) {
|
||||
console.error("animateToastProgress: elements not found", toastId);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
let totalPausedTime = 0;
|
||||
let pauseStartTime = null;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const el = document.getElementById(toastId);
|
||||
const progEl = document.getElementById(toastId + "-progress");
|
||||
|
||||
if (!el || !progEl) return; // Toast was manually removed
|
||||
|
||||
// Check if paused (set via onmouseenter/onmouseleave)
|
||||
const isPaused = el.dataset.paused === "true";
|
||||
|
||||
if (isPaused) {
|
||||
// Track when pause started
|
||||
if (pauseStartTime === null) {
|
||||
pauseStartTime = currentTime;
|
||||
}
|
||||
// Keep animating while paused
|
||||
requestAnimationFrame(animate);
|
||||
return;
|
||||
} else {
|
||||
// If we were paused, accumulate the paused time
|
||||
if (pauseStartTime !== null) {
|
||||
totalPausedTime += currentTime - pauseStartTime;
|
||||
pauseStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual elapsed time (excluding paused time)
|
||||
const elapsed = currentTime - startTime - totalPausedTime;
|
||||
// Progress bar goes from 100% to 0%
|
||||
const progress = Math.max(100 - (elapsed / duration) * 100, 0);
|
||||
progEl.style.width = progress + "%";
|
||||
|
||||
if (elapsed >= duration) {
|
||||
// Remove the toast element
|
||||
el.remove();
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
54
pkg/embedfs/files/js/toasts.js
Normal file
54
pkg/embedfs/files/js/toasts.js
Normal file
@@ -0,0 +1,54 @@
|
||||
function progressBar(toastId, duration) {
|
||||
const progressBar = {
|
||||
progress: 0,
|
||||
paused: false,
|
||||
|
||||
animateToastProgress() {
|
||||
const toast = document.getElementById(toastId);
|
||||
if (!toast) return;
|
||||
const bar = document.getElementById([toastId, "progress"].join("-"));
|
||||
|
||||
const startTime = performance.now();
|
||||
let totalPausedTime = 0;
|
||||
let pauseStartTime = null;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const toast = document.getElementById(toastId);
|
||||
if (!toast) return; // Toast was manually removed
|
||||
|
||||
if (this.paused) {
|
||||
// Track when pause started
|
||||
if (pauseStartTime === null) {
|
||||
pauseStartTime = currentTime;
|
||||
}
|
||||
// Keep animating while paused
|
||||
requestAnimationFrame(animate);
|
||||
return;
|
||||
} else {
|
||||
// If we were paused, accumulate the paused time
|
||||
if (pauseStartTime !== null) {
|
||||
totalPausedTime += currentTime - pauseStartTime;
|
||||
pauseStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual elapsed time (excluding paused time)
|
||||
const elapsed = currentTime - startTime - totalPausedTime;
|
||||
this.progress = Math.min((elapsed / duration) * 100, 100) + "%";
|
||||
bar.style["width"] = this.progress;
|
||||
|
||||
|
||||
if (elapsed >= duration) {
|
||||
toast.remove()
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
progressBar.animateToastProgress();
|
||||
return progressBar;
|
||||
}
|
||||
|
||||
5
pkg/embedfs/files/vendored/alpinejs@3.15.4.min.js
vendored
Normal file
5
pkg/embedfs/files/vendored/alpinejs@3.15.4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
pkg/embedfs/files/vendored/htmx-ext-ws.min.js
vendored
Normal file
1
pkg/embedfs/files/vendored/htmx-ext-ws.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
pkg/embedfs/files/vendored/htmx@2.0.8.min.js
vendored
Normal file
1
pkg/embedfs/files/vendored/htmx@2.0.8.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user