A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

Select the types of activity you want to include in your feed.

impeccable:harden on all admin panel

+1134 -318
+135 -21
pkg/hold/admin/admin.go
··· 6 6 //go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'" 7 7 8 8 import ( 9 + "bytes" 9 10 "context" 10 11 "crypto/rand" 11 12 "embed" ··· 54 55 } 55 56 } 56 57 57 - // AdminSession represents an authenticated admin session 58 + // AdminSession represents an authenticated admin session. UserAgent and 59 + // IPPrefix are captured at login and rechecked on every request — a stolen 60 + // token replayed from a different browser or network prefix is rejected and 61 + // the session is torn down. Binding at /24 (IPv4) / /64 (IPv6) tolerates 62 + // DHCP renewals within a prefix without inviting cross-network replay. 58 63 type AdminSession struct { 59 - DID string 60 - Handle string 64 + DID string 65 + Handle string 66 + CSRFToken string 67 + CreatedAt time.Time 68 + UserAgent string 69 + IPPrefix string 61 70 } 71 + 72 + // sessionTTL is the server-side lifetime of an admin session. Sessions older 73 + // than this are treated as expired regardless of cookie state. 74 + const sessionTTL = 24 * time.Hour 62 75 63 76 // AdminUI manages the admin web interface 64 77 type AdminUI struct { ··· 69 82 templates map[string]*template.Template 70 83 config AdminConfig 71 84 85 + // secureCookies indicates cookies should carry the Secure flag regardless 86 + // of per-request proxy header state. Set at init from PublicURL scheme so 87 + // a misconfigured reverse proxy can't silently drop Secure. 88 + secureCookies bool 89 + 72 90 // In-memory session storage (single user, no persistence needed) 73 91 sessions map[string]*AdminSession 74 92 sessionsMu sync.RWMutex ··· 139 157 } 140 158 141 159 ui := &AdminUI{ 142 - pds: holdPDS, 143 - quotaMgr: quotaMgr, 144 - gc: garbageCollector, 145 - clientApp: clientApp, 146 - templates: templates, 147 - config: cfg, 148 - sessions: make(map[string]*AdminSession), 160 + pds: holdPDS, 161 + quotaMgr: quotaMgr, 162 + gc: garbageCollector, 163 + clientApp: clientApp, 164 + templates: templates, 165 + config: cfg, 166 + secureCookies: strings.HasPrefix(cfg.PublicURL, "https://"), 167 + sessions: make(map[string]*AdminSession), 149 168 } 150 169 151 170 slog.Info("Admin panel initialized", "publicURL", cfg.PublicURL) ··· 155 174 156 175 // Session management 157 176 158 - func (ui *AdminUI) createSession(did, handle string) (string, error) { 177 + func (ui *AdminUI) createSession(did, handle, userAgent, ipPrefix string) (string, error) { 159 178 b := make([]byte, 32) 160 179 if _, err := rand.Read(b); err != nil { 161 180 return "", fmt.Errorf("failed to create session token: %w", err) 162 181 } 163 182 token := base64.URLEncoding.EncodeToString(b) 164 183 184 + csrfToken, err := generateCSRFToken() 185 + if err != nil { 186 + return "", err 187 + } 188 + 165 189 ui.sessionsMu.Lock() 166 - ui.sessions[token] = &AdminSession{DID: did, Handle: handle} 190 + ui.sessions[token] = &AdminSession{ 191 + DID: did, 192 + Handle: handle, 193 + CSRFToken: csrfToken, 194 + CreatedAt: time.Now(), 195 + UserAgent: userAgent, 196 + IPPrefix: ipPrefix, 197 + } 167 198 ui.sessionsMu.Unlock() 168 199 169 200 return token, nil 170 201 } 171 202 203 + // getSession returns the session for the given token, or nil if missing or 204 + // expired. Expired sessions are evicted on access to keep the in-memory map 205 + // bounded even if the user never hits logout. 172 206 func (ui *AdminUI) getSession(token string) *AdminSession { 173 207 ui.sessionsMu.RLock() 174 - defer ui.sessionsMu.RUnlock() 175 - return ui.sessions[token] 208 + session := ui.sessions[token] 209 + ui.sessionsMu.RUnlock() 210 + if session == nil { 211 + return nil 212 + } 213 + if !session.CreatedAt.IsZero() && time.Since(session.CreatedAt) > sessionTTL { 214 + ui.sessionsMu.Lock() 215 + delete(ui.sessions, token) 216 + ui.sessionsMu.Unlock() 217 + return nil 218 + } 219 + return session 176 220 } 177 221 178 222 func (ui *AdminUI) deleteSession(token string) { ··· 185 229 186 230 const sessionCookieName = "hold_admin_session" 187 231 232 + // secureForRequest returns whether the Secure flag should be set on admin 233 + // cookies for this request. True if the hold is served over HTTPS (derived 234 + // from PublicURL at init) OR the request itself is TLS-terminated or came 235 + // through a proxy that advertised https. Union rules out the case where a 236 + // reverse proxy forgets to set X-Forwarded-Proto — PublicURL is the source 237 + // of truth. 238 + func (ui *AdminUI) secureForRequest(r *http.Request) bool { 239 + return ui.secureCookies || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" 240 + } 241 + 188 242 func (ui *AdminUI) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { 189 - secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" 243 + // SameSite=Lax (not Strict). Strict would drop the session cookie on 244 + // the redirect chain coming back from the OAuth provider: the browser 245 + // classifies the post-callback navigation to /admin as not-same-site 246 + // because the chain was initiated from the PDS, and the user lands 247 + // back on the login page. Lax still blocks the dominant CSRF vectors 248 + // (cross-site form POSTs, image/XHR requests) and the CSRF middleware 249 + // covers what Lax doesn't. 190 250 http.SetCookie(w, &http.Cookie{ 191 251 Name: sessionCookieName, 192 252 Value: token, 193 253 Path: "/admin", 194 254 MaxAge: 86400, // 24 hours 195 255 HttpOnly: true, 196 - Secure: secure, 256 + Secure: ui.secureForRequest(r), 197 257 SameSite: http.SameSiteLaxMode, 198 258 }) 199 259 } ··· 217 277 return cookie.Value, true 218 278 } 219 279 280 + // clientIPPrefix returns a stable prefix key for the request's client IP. 281 + // /24 for IPv4, /64 for IPv6. Returns empty string if the address is 282 + // unparseable — callers treat "" as "don't bind" to avoid locking users out 283 + // behind unusual proxies (Unix sockets, tests, etc.). 284 + func clientIPPrefix(r *http.Request) string { 285 + var host string 286 + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { 287 + // Leftmost entry is the original client. 288 + if comma := strings.IndexByte(fwd, ','); comma >= 0 { 289 + host = strings.TrimSpace(fwd[:comma]) 290 + } else { 291 + host = strings.TrimSpace(fwd) 292 + } 293 + } else { 294 + h, _, err := net.SplitHostPort(r.RemoteAddr) 295 + if err == nil { 296 + host = h 297 + } else { 298 + host = r.RemoteAddr 299 + } 300 + } 301 + ip := net.ParseIP(host) 302 + if ip == nil { 303 + return "" 304 + } 305 + if v4 := ip.To4(); v4 != nil { 306 + return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2]) 307 + } 308 + v6 := ip.To16() 309 + return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x", 310 + v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7]) 311 + } 312 + 220 313 // parseTemplates loads and parses all HTML templates. 221 314 // Components (including layout) are parsed into a base template. Each page and 222 315 // partial gets its own clone of the base so that {{block}} overrides don't conflict. ··· 258 351 template.HTMLEscapeString(name), 259 352 )) 260 353 }, 354 + // csrfInput emits a hidden input carrying the per-session CSRF token. 355 + // Usage: {{ csrfInput .CSRFToken }} 356 + "csrfInput": csrfInputHTML, 357 + // loginError maps a slug from the login error query parameter to a 358 + // user-friendly message. Unknown slugs produce a generic fallback so 359 + // internal error details are never surfaced to the browser. 360 + "loginError": loginErrorMessage, 261 361 } 262 362 263 363 // Collect template files by category ··· 358 458 // OAuth client metadata endpoint (required for production OAuth) 359 459 r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata) 360 460 361 - // Public auth routes 461 + // Public auth routes. Authorize is POST-only — the handle is the user's 462 + // identity and must not land in browser history, access logs, or 463 + // Referer headers. 362 464 r.Get("/admin/auth/login", ui.handleLogin) 363 - r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize) 465 + r.Post("/admin/auth/oauth/authorize", ui.handleAuthorize) 364 466 r.Get("/admin/auth/oauth/callback", ui.handleCallback) 365 467 366 468 // Protected routes (require owner) 367 469 r.Group(func(r chi.Router) { 368 470 r.Use(ui.requireOwner) 471 + // CSRF check runs after requireOwner so the session (and thus the 472 + // per-session token to compare against) is already on the context. 473 + r.Use(ui.requireCSRF) 369 474 370 475 // Single admin page (client-side tab switching) 371 476 r.Get("/admin", ui.handleAdmin) ··· 440 545 metadata.ClientName = &clientName 441 546 metadata.ClientURI = &ui.config.PublicURL 442 547 548 + // Encode into a buffer first so an encode failure can produce a clean 549 + // 500 response. Writing directly to w commits the 200 header at the 550 + // first byte, after which WriteHeader becomes a no-op. 551 + var buf bytes.Buffer 552 + if err := json.NewEncoder(&buf).Encode(metadata); err != nil { 553 + slog.Error("failed to encode client metadata", "error", err, "path", r.URL.Path) 554 + http.Error(w, "Internal server error", http.StatusInternalServerError) 555 + return 556 + } 557 + 443 558 w.Header().Set("Content-Type", "application/json") 444 559 w.Header().Set("Cache-Control", "public, max-age=3600") 445 - if err := json.NewEncoder(w).Encode(metadata); err != nil { 446 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 447 - w.WriteHeader(http.StatusInternalServerError) 560 + if _, err := w.Write(buf.Bytes()); err != nil { 561 + slog.Debug("client metadata write failed", "error", err, "path", r.URL.Path) 448 562 } 449 563 } 450 564
+51 -1
pkg/hold/admin/auth.go
··· 7 7 "strings" 8 8 ) 9 9 10 - // requireOwner middleware ensures the request is from the hold owner 10 + // requireOwner middleware ensures the request is from the hold owner. 11 + // Enforces three layers: 12 + // 1. Session token exists and has not expired (24h absolute TTL). 13 + // 2. Session's DID still matches captain.Owner in the PDS — if ownership 14 + // changes mid-session, the old session is torn down on the next hit. 15 + // 3. Browser User-Agent and network prefix match what was captured at 16 + // login. A mismatch means the cookie is being replayed from a 17 + // different browser or network and the session is invalidated. 11 18 func (ui *AdminUI) requireOwner(next http.Handler) http.Handler { 12 19 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 20 // Get session cookie ··· 43 50 return 44 51 } 45 52 53 + // User-Agent / IP-prefix binding. Empty bound values (e.g. from 54 + // clients with no UA, requests over Unix sockets) are not 55 + // compared — binding opts out rather than locking the user out. 56 + if session.UserAgent != "" && session.UserAgent != r.UserAgent() { 57 + slog.Warn("Admin session User-Agent mismatch — suspected token replay", 58 + "did", session.DID) 59 + ui.deleteSession(token) 60 + clearSessionCookie(w) 61 + http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound) 62 + return 63 + } 64 + if session.IPPrefix != "" { 65 + if now := clientIPPrefix(r); now != "" && now != session.IPPrefix { 66 + slog.Warn("Admin session IP prefix mismatch — suspected token replay", 67 + "did", session.DID, 68 + "sessionPrefix", session.IPPrefix, 69 + "requestPrefix", now) 70 + ui.deleteSession(token) 71 + clearSessionCookie(w) 72 + http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound) 73 + return 74 + } 75 + } 76 + 46 77 // Add session to context for handlers 47 78 ctx := context.WithValue(r.Context(), adminContextKey{}, session) 48 79 next.ServeHTTP(w, r.WithContext(ctx)) ··· 58 89 return session 59 90 } 60 91 92 + // sessionDIDFromContext returns the authenticated DID for log annotation, 93 + // empty if there is no session (e.g. an early-rejected request). 94 + func sessionDIDFromContext(ctx context.Context) string { 95 + if s := getSessionFromContext(ctx); s != nil { 96 + return s.DID 97 + } 98 + return "" 99 + } 100 + 61 101 // PageData contains common data for all admin pages 62 102 type PageData struct { 63 103 Title string ··· 65 105 User *AdminSession 66 106 HoldDID string 67 107 Flash *Flash 108 + // CSRFToken is the per-session token that forms and htmx must echo back 109 + // on state-mutating requests. Threaded to <body hx-headers=...> at the 110 + // layout level; plain forms emit it via {{ csrfInput .CSRFToken }}. 111 + CSRFToken string 68 112 } 69 113 70 114 // Flash represents a flash message ··· 78 122 session := getSessionFromContext(r.Context()) 79 123 flash := getFlash(r, ui) 80 124 125 + var csrf string 126 + if session != nil { 127 + csrf = session.CSRFToken 128 + } 129 + 81 130 return PageData{ 82 131 Title: title, 83 132 ActivePage: activePage, 84 133 User: session, 85 134 HoldDID: ui.pds.DID(), 86 135 Flash: flash, 136 + CSRFToken: csrf, 87 137 } 88 138 } 89 139
+112
pkg/hold/admin/csrf.go
··· 1 + package admin 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/subtle" 6 + "encoding/base64" 7 + "fmt" 8 + "html/template" 9 + "log/slog" 10 + "net/http" 11 + "strings" 12 + ) 13 + 14 + const ( 15 + csrfHeaderName = "X-CSRF-Token" 16 + csrfFormField = "csrf_token" 17 + ) 18 + 19 + // generateCSRFToken returns a cryptographically random token. 20 + // 32 bytes (256 bits) base64url-encoded, matching the session token format. 21 + func generateCSRFToken() (string, error) { 22 + b := make([]byte, 32) 23 + if _, err := rand.Read(b); err != nil { 24 + return "", fmt.Errorf("generate csrf token: %w", err) 25 + } 26 + return base64.URLEncoding.EncodeToString(b), nil 27 + } 28 + 29 + // requireCSRF validates a per-session CSRF token on state-mutating requests. 30 + // Safe methods (GET/HEAD/OPTIONS) are unchecked; everything else must supply 31 + // the token via the X-CSRF-Token header (htmx path) or the csrf_token form 32 + // field on application/x-www-form-urlencoded bodies (plain-form path). 33 + // Multipart bodies are rejected unless the header is present — this avoids 34 + // consuming a multipart body in middleware and stepping on per-handler size 35 + // limits such as http.MaxBytesReader. 36 + // 37 + // Must run after requireOwner so a session is present on the request context. 38 + func (ui *AdminUI) requireCSRF(next http.Handler) http.Handler { 39 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 + switch r.Method { 41 + case http.MethodGet, http.MethodHead, http.MethodOptions: 42 + next.ServeHTTP(w, r) 43 + return 44 + } 45 + 46 + session := getSessionFromContext(r.Context()) 47 + if session == nil || session.CSRFToken == "" { 48 + slog.Warn("CSRF check failed: no session token", 49 + "path", r.URL.Path, "method", r.Method) 50 + csrfReject(w, r, "missing session") 51 + return 52 + } 53 + 54 + got := r.Header.Get(csrfHeaderName) 55 + if got == "" { 56 + contentType := r.Header.Get("Content-Type") 57 + // Split off any ;boundary=... suffix before comparing. 58 + if idx := strings.IndexByte(contentType, ';'); idx >= 0 { 59 + contentType = contentType[:idx] 60 + } 61 + contentType = strings.TrimSpace(strings.ToLower(contentType)) 62 + switch contentType { 63 + case "application/x-www-form-urlencoded": 64 + if err := r.ParseForm(); err == nil { 65 + got = r.PostFormValue(csrfFormField) 66 + } 67 + case "multipart/form-data": 68 + // Parse multipart with a small limit just to read the CSRF 69 + // field. 32 KB is enough for the token + file metadata without 70 + // loading uploaded file data into memory — Go stores the file 71 + // portion beyond maxMemory on disk. 72 + const csrfMultipartMaxMem = 32 << 10 // 32 KB 73 + if err := r.ParseMultipartForm(csrfMultipartMaxMem); err == nil { 74 + got = r.FormValue(csrfFormField) 75 + } 76 + } 77 + } 78 + 79 + if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 { 80 + slog.Warn("CSRF token mismatch", 81 + "path", r.URL.Path, 82 + "method", r.Method, 83 + "did", session.DID, 84 + "provided", got != "") 85 + csrfReject(w, r, "token mismatch") 86 + return 87 + } 88 + next.ServeHTTP(w, r) 89 + }) 90 + } 91 + 92 + // csrfReject returns a 403. For htmx requests it surfaces a toast via the 93 + // standard HX-Trigger channel so the page-level error handler can announce 94 + // the failure; for plain browsers it's a text response. 95 + func csrfReject(w http.ResponseWriter, r *http.Request, reason string) { 96 + const userMsg = "Session expired or CSRF token invalid — reload the page and try again." 97 + if r.Header.Get("HX-Request") == "true" { 98 + w.Header().Set("HX-Trigger", 99 + `{"toast":{"message":"`+userMsg+`","type":"error"}}`) 100 + w.Header().Set("HX-Reswap", "none") 101 + w.WriteHeader(http.StatusForbidden) 102 + return 103 + } 104 + http.Error(w, "Forbidden: "+userMsg, http.StatusForbidden) 105 + } 106 + 107 + // csrfInputHTML returns a hidden form input carrying the CSRF token, safely 108 + // escaped for attribute context. 109 + func csrfInputHTML(token string) template.HTML { 110 + escaped := template.HTMLEscapeString(token) 111 + return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`) 112 + }
+37
pkg/hold/admin/errors.go
··· 1 + package admin 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + ) 8 + 9 + // renderHTMXError sends an error response suitable for htmx. For htmx 10 + // requests it sets HX-Trigger so the client fires a toast; the global 11 + // htmx:responseError listener in main.js is the fallback for non-triggering 12 + // handlers. For plain browsers it degrades to http.Error. serverErr is 13 + // logged but never exposed — pass userMsg for anything user-visible. 14 + func renderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) { 15 + if serverErr != nil { 16 + slog.Error("admin htmx handler error", 17 + "path", r.URL.Path, 18 + "status", status, 19 + "err", serverErr, 20 + ) 21 + } 22 + if userMsg == "" { 23 + userMsg = http.StatusText(status) 24 + } 25 + if r.Header.Get("HX-Request") == "true" { 26 + trigger := map[string]map[string]string{ 27 + "toast": {"message": userMsg, "type": "error"}, 28 + } 29 + if b, err := json.Marshal(trigger); err == nil { 30 + w.Header().Set("HX-Trigger", string(b)) 31 + } 32 + w.Header().Set("HX-Reswap", "none") 33 + w.WriteHeader(status) 34 + return 35 + } 36 + http.Error(w, userMsg, status) 37 + }
+26 -4
pkg/hold/admin/flash.go
··· 8 8 9 9 const flashCookieName = "hold_admin_flash" 10 10 11 - // setFlash sets a flash message cookie 11 + // validFlashCategories bounds what can appear in alert class interpolation. 12 + // Anything outside this set is coerced to "info" before the cookie is set. 13 + var validFlashCategories = map[string]bool{ 14 + "success": true, 15 + "error": true, 16 + "warning": true, 17 + "info": true, 18 + } 19 + 20 + // setFlash sets a flash message cookie. Category is coerced to "info" if 21 + // not on the known allowlist — the value flows into an HTML class attribute 22 + // in layout.html and must not carry arbitrary text. 12 23 func setFlash(w http.ResponseWriter, r *http.Request, category, message string) { 24 + if !validFlashCategories[category] { 25 + category = "info" 26 + } 13 27 flash := Flash{ 14 28 Category: category, 15 29 Message: message, ··· 30 44 MaxAge: 60, // 1 minute - should be consumed on next page load 31 45 HttpOnly: true, 32 46 Secure: secure, 33 - SameSite: http.SameSiteLaxMode, 47 + SameSite: http.SameSiteStrictMode, 34 48 }) 35 49 } 36 50 37 - // getFlash retrieves and clears the flash message 51 + // getFlash retrieves the flash message. Callers that render the flash into 52 + // the page layout should arrange for the cookie to be cleared after display 53 + // via clearFlash — handlers typically defer clearFlash(w) at entry. 54 + // getFlash also rejects flashes whose category has drifted off the 55 + // allowlist (e.g. a forged or pre-upgrade cookie). 38 56 func getFlash(r *http.Request, ui *AdminUI) *Flash { 39 57 cookie, err := r.Cookie(flashCookieName) 40 58 if err != nil { ··· 51 69 return nil 52 70 } 53 71 72 + if !validFlashCategories[flash.Category] { 73 + flash.Category = "info" 74 + } 75 + 54 76 return &flash 55 77 } 56 78 ··· 62 84 Path: "/admin", 63 85 MaxAge: -1, 64 86 HttpOnly: true, 65 - SameSite: http.SameSiteLaxMode, 87 + SameSite: http.SameSiteStrictMode, 66 88 }) 67 89 }
+2 -6
pkg/hold/admin/handlers.go
··· 20 20 // handleAdmin renders the single admin page with client-side tab switching 21 21 func (ui *AdminUI) handleAdmin(w http.ResponseWriter, r *http.Request) { 22 22 defer clearFlash(w) 23 - data := struct { 24 - PageData 25 - }{ 26 - PageData: ui.newPageData(r, "Hold Admin", ""), 27 - } 28 - ui.renderTemplate(w, "pages/admin.html", data) 23 + ui.renderTemplate(w, "pages/admin.html", ui.newPageData(r, "Hold Admin", "")) 29 24 } 30 25 31 26 // getDashboardStats returns dashboard statistics ··· 58 53 59 54 // handleDashboardTab returns the dashboard tab content (HTMX partial) 60 55 func (ui *AdminUI) handleDashboardTab(w http.ResponseWriter, r *http.Request) { 56 + defer clearFlash(w) 61 57 data := struct { 62 58 Stats DashboardStats 63 59 }{
+34 -10
pkg/hold/admin/handlers_auth.go
··· 8 8 "atcr.io/pkg/atproto" 9 9 ) 10 10 11 + // loginErrorMessages maps slug-based error codes (emitted as query params by the 12 + // auth handlers) to user-friendly sentences. Any slug not in this map falls back 13 + // to a generic message so internal details are never exposed in the browser. 14 + var loginErrorMessages = map[string]string{ 15 + "handle_required": "Please enter a handle or DID to sign in.", 16 + "handle_invalid": "That handle or DID could not be found. Check the spelling and try again.", 17 + "oauth_failed": "Couldn't start the sign-in flow. Please try again.", 18 + "oauth_callback_failed": "Sign-in was not completed. Please try again.", 19 + "ownership_check_failed": "Couldn't verify hold ownership. Please try again.", 20 + "access_denied": "Only the hold owner can access the admin panel.", 21 + } 22 + 23 + // loginErrorMessage translates a slug into a human-readable login error. 24 + // It is registered as the "loginError" template function. 25 + func loginErrorMessage(slug string) string { 26 + if msg, ok := loginErrorMessages[slug]; ok { 27 + return msg 28 + } 29 + return "Couldn't sign you in. Please try again." 30 + } 31 + 11 32 // handleLogin renders the login page 12 33 func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) { 13 34 // If already logged in, redirect to dashboard ··· 36 57 ui.renderTemplate(w, "pages/login.html", data) 37 58 } 38 59 39 - // handleAuthorize starts the OAuth flow 60 + // handleAuthorize starts the OAuth flow. 61 + // Accepts both GET (direct URL) and POST (login form submission). 40 62 func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) { 41 - handle := strings.TrimSpace(r.URL.Query().Get("handle")) 63 + handle := strings.TrimSpace(r.FormValue("handle")) 42 64 if handle == "" { 43 - http.Redirect(w, r, "/admin/auth/login?error=Handle+is+required", http.StatusFound) 65 + http.Redirect(w, r, "/admin/auth/login?error=handle_required", http.StatusFound) 44 66 return 45 67 } 46 68 ··· 51 73 did, _, _, err := atproto.ResolveIdentity(r.Context(), handle) 52 74 if err != nil { 53 75 slog.Warn("Failed to resolve handle for admin login", "handle", handle, "error", err) 54 - http.Redirect(w, r, "/admin/auth/login?error=Could+not+resolve+handle", http.StatusFound) 76 + http.Redirect(w, r, "/admin/auth/login?error=handle_invalid", http.StatusFound) 55 77 return 56 78 } 57 79 ··· 61 83 authURL, err := ui.clientApp.StartAuthFlow(r.Context(), did) 62 84 if err != nil { 63 85 slog.Error("Failed to start OAuth flow", "error", err) 64 - http.Redirect(w, r, "/admin/auth/login?error=OAuth+initialization+failed", http.StatusFound) 86 + http.Redirect(w, r, "/admin/auth/login?error=oauth_failed", http.StatusFound) 65 87 return 66 88 } 67 89 ··· 76 98 sessionData, err := ui.clientApp.ProcessCallback(ctx, r.URL.Query()) 77 99 if err != nil { 78 100 slog.Error("OAuth callback failed", "error", err) 79 - http.Redirect(w, r, "/admin/auth/login?error=OAuth+authentication+failed", http.StatusFound) 101 + http.Redirect(w, r, "/admin/auth/login?error=oauth_callback_failed", http.StatusFound) 80 102 return 81 103 } 82 104 ··· 95 117 _, captain, err := ui.pds.GetCaptainRecord(ctx) 96 118 if err != nil { 97 119 slog.Error("Failed to get captain record during OAuth callback", "error", err) 98 - http.Redirect(w, r, "/admin/auth/login?error=Failed+to+verify+ownership", http.StatusFound) 120 + http.Redirect(w, r, "/admin/auth/login?error=ownership_check_failed", http.StatusFound) 99 121 return 100 122 } 101 123 ··· 105 127 "did", did, 106 128 "handle", handle, 107 129 "owner", captain.Owner) 108 - http.Redirect(w, r, "/admin/auth/login?error=Access+denied:+Only+the+hold+owner+can+access+the+admin+panel", http.StatusFound) 130 + http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound) 109 131 return 110 132 } 111 133 112 - // Create session and set cookie 113 - token, err := ui.createSession(did, handle) 134 + // Create session and set cookie. Bind to the browser's User-Agent and 135 + // network prefix at login so a stolen cookie replayed from a different 136 + // browser or /24 can't be used. 137 + token, err := ui.createSession(did, handle, r.UserAgent(), clientIPPrefix(r)) 114 138 if err != nil { 115 139 slog.Error("failed to create session token", "error", err, "path", r.URL.Path) 116 140 http.Error(w, "Failed to create session", http.StatusInternalServerError)
+52 -21
pkg/hold/admin/handlers_crew.go
··· 4 4 "context" 5 5 "log/slog" 6 6 "net/http" 7 + "net/url" 7 8 "sort" 8 9 "strings" 9 10 "time" ··· 54 55 // Includes usage data (fast bulk SQL query) for correct sort order. 55 56 // Handles are lazy-loaded per-row via handleCrewMemberInfo. 56 57 func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) { 58 + defer clearFlash(w) 57 59 crew, err := ui.pds.ListCrewMembers(r.Context()) 58 60 if err != nil { 59 - http.Error(w, "Failed to list crew: "+err.Error(), http.StatusInternalServerError) 61 + renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load crew", err) 60 62 return 61 63 } 62 64 ··· 307 309 ctx := r.Context() 308 310 rkey := chi.URLParam(r, "rkey") 309 311 312 + editURL, _ := url.JoinPath("/admin/crew/", rkey) 313 + 310 314 if err := r.ParseForm(); err != nil { 311 315 setFlash(w, r, "error", "Invalid form data") 312 - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) 316 + http.Redirect(w, r, editURL, http.StatusFound) 313 317 return 314 318 } 315 319 ··· 321 325 return 322 326 } 323 327 328 + // Owner's crew record is immutable via the admin UI — the template 329 + // disables the inputs but defense-in-depth requires a server-side gate 330 + // in case the form is bypassed. 331 + _, captain, capErr := ui.pds.GetCaptainRecord(ctx) 332 + if capErr == nil && captain != nil && current.Member == captain.Owner { 333 + setFlash(w, r, "error", "Owner permissions cannot be modified") 334 + http.Redirect(w, r, "/admin#crew", http.StatusFound) 335 + return 336 + } 337 + 324 338 // Parse new values 325 339 role := r.FormValue("role") 326 340 tier := r.FormValue("tier") ··· 340 354 if tier != current.Tier { 341 355 if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { 342 356 setFlash(w, r, "error", "Failed to update tier: "+err.Error()) 343 - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) 357 + http.Redirect(w, r, editURL, http.StatusFound) 344 358 return 345 359 } 346 360 } 347 361 348 - // For role/permissions changes, need to delete and recreate 349 - // (ATProto records are immutable, updates require delete+create) 350 - if role != current.Role || !slicesEqual(permissions, current.Permissions) { 351 - // Delete old record 352 - if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { 353 - setFlash(w, r, "error", "Failed to update: "+err.Error()) 354 - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) 362 + // For role/permissions changes, need to delete and recreate — ATProto 363 + // records are immutable. Create-then-delete (instead of delete-then- 364 + // create): if creation fails the old record is still there, so the 365 + // worst outcome is a transient duplicate rather than a silently- 366 + // deleted member. 367 + if role != current.Role || !permissionsEqual(permissions, current.Permissions) { 368 + if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil { 369 + setFlash(w, r, "error", "Failed to update crew record: "+err.Error()) 370 + http.Redirect(w, r, editURL, http.StatusFound) 355 371 return 356 372 } 357 - 358 - // Create new record with updated values (including tier) 359 - if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil { 360 - setFlash(w, r, "error", "Failed to recreate crew record: "+err.Error()) 373 + if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { 374 + slog.Error("Failed to remove old crew record after replacement", 375 + "rkey", rkey, "member", current.Member, "error", err) 376 + // The new record is already live; surface a non-fatal warning 377 + // and return to the crew list. 378 + setFlash(w, r, "warning", "Update succeeded but old record may linger: "+err.Error()) 361 379 http.Redirect(w, r, "/admin#crew", http.StatusFound) 362 380 return 363 381 } ··· 404 422 405 423 slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID) 406 424 407 - // For HTMX requests, return empty response (row will be removed) 425 + // For HTMX requests, return 204 No Content. The row uses 426 + // hx-swap="outerHTML" so htmx replaces it with the empty response body 427 + // and the row disappears. Explicit 204 is the stable idiom (plain 200 428 + // with empty body works today but is implementation-defined). 408 429 if r.Header.Get("HX-Request") == "true" { 409 - w.WriteHeader(http.StatusOK) 430 + w.WriteHeader(http.StatusNoContent) 410 431 return 411 432 } 412 433 ··· 438 459 return options 439 460 } 440 461 441 - // slicesEqual checks if two string slices contain the same elements 442 - func slicesEqual(a, b []string) bool { 462 + // permissionsEqual checks if two permission slices contain the same set of 463 + // entries. Order-independent — permissions are an unordered set, not a list. 464 + func permissionsEqual(a, b []string) bool { 443 465 if len(a) != len(b) { 444 466 return false 445 467 } 446 - aMap := make(map[string]bool) 468 + aMap := make(map[string]bool, len(a)) 447 469 for _, v := range a { 448 470 aMap[v] = true 449 471 } ··· 455 477 return true 456 478 } 457 479 458 - // parseTime parses an RFC3339 timestamp 480 + // parseTime parses an RFC3339 timestamp. Malformed values return the zero 481 + // time so callers can use t.IsZero() as a guard; the debug log lets an 482 + // operator trace data corruption that the UI otherwise hides. 459 483 func parseTime(s string) time.Time { 460 - t, _ := time.Parse(time.RFC3339, s) 484 + if s == "" { 485 + return time.Time{} 486 + } 487 + t, err := time.Parse(time.RFC3339, s) 488 + if err != nil { 489 + slog.Debug("parseTime: malformed RFC3339 timestamp", "value", s, "error", err) 490 + return time.Time{} 491 + } 461 492 return t 462 493 }
+3 -1
pkg/hold/admin/handlers_crew_io.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "log/slog" 7 + "mime" 7 8 "net/http" 8 9 "strings" 9 10 "time" ··· 62 63 } 63 64 64 65 filename := "crew-export-" + time.Now().Format("2006-01-02") + ".json" 66 + disposition := mime.FormatMediaType("attachment", map[string]string{"filename": filename}) 65 67 w.Header().Set("Content-Type", "application/json") 66 - w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) 68 + w.Header().Set("Content-Disposition", disposition) 67 69 68 70 enc := json.NewEncoder(w) 69 71 enc.SetIndent("", " ")
+1
pkg/hold/admin/handlers_gc.go
··· 30 30 31 31 // handleGCTab returns the storage/GC tab content (HTMX partial) 32 32 func (ui *AdminUI) handleGCTab(w http.ResponseWriter, r *http.Request) { 33 + defer clearFlash(w) 33 34 if ui.gc == nil { 34 35 ui.renderTemplate(w, "partials/tab_storage.html", gcTabData{}) 35 36 return
+30 -2
pkg/hold/admin/handlers_relays.go
··· 41 41 return relays 42 42 } 43 43 44 + // knownRelayByURL returns the matching KnownRelay (exact URL match) or nil 45 + // if the URL is not on the allowlist. Used to gate outbound HTTP from the 46 + // status/crawl handlers — the `url` query parameter is attacker-controllable 47 + // in principle, so we restrict it to vetted relay endpoints. 48 + func knownRelayByURL(relayURL string) *atproto.KnownRelay { 49 + for i := range atproto.KnownRelays { 50 + if atproto.KnownRelays[i].URL == relayURL { 51 + return &atproto.KnownRelays[i] 52 + } 53 + } 54 + return nil 55 + } 56 + 44 57 // handleRelaysTab returns the relays tab content (HTMX partial) 45 58 func (ui *AdminUI) handleRelaysTab(w http.ResponseWriter, r *http.Request) { 59 + defer clearFlash(w) 46 60 data := struct { 47 61 Relays []RelayView 48 62 }{ ··· 54 68 // handleRelayStatus returns an HTMX partial with a relay's full status row. 55 69 func (ui *AdminUI) handleRelayStatus(w http.ResponseWriter, r *http.Request) { 56 70 relayURL := r.URL.Query().Get("url") 57 - relayName := r.URL.Query().Get("name") 58 71 if relayURL == "" { 59 72 http.Error(w, "Missing url parameter", http.StatusBadRequest) 60 73 return 61 74 } 75 + known := knownRelayByURL(relayURL) 76 + if known == nil { 77 + slog.Warn("Admin relay status request for non-allowlisted URL", 78 + "url", relayURL, "did", sessionDIDFromContext(r.Context())) 79 + http.Error(w, "Unknown relay", http.StatusBadRequest) 80 + return 81 + } 82 + relayName := known.Name 62 83 63 84 parsed, err := url.Parse(ui.config.PublicURL) 64 85 if err != nil { ··· 99 120 // handleRelayCrawl requests crawl from a single relay and returns an HTMX partial. 100 121 func (ui *AdminUI) handleRelayCrawl(w http.ResponseWriter, r *http.Request) { 101 122 relayURL := r.URL.Query().Get("url") 102 - relayName := r.URL.Query().Get("name") 103 123 if relayURL == "" { 104 124 http.Error(w, "Missing relay URL", http.StatusBadRequest) 105 125 return 106 126 } 127 + known := knownRelayByURL(relayURL) 128 + if known == nil { 129 + slog.Warn("Admin relay crawl request for non-allowlisted URL", 130 + "url", relayURL, "did", sessionDIDFromContext(r.Context())) 131 + http.Error(w, "Unknown relay", http.StatusBadRequest) 132 + return 133 + } 134 + relayName := known.Name 107 135 108 136 err := atproto.RequestCrawl(relayURL, ui.config.PublicURL) 109 137
+58 -15
pkg/hold/admin/handlers_settings.go
··· 57 57 58 58 // handleSettingsTab returns the settings tab content (HTMX partial) 59 59 func (ui *AdminUI) handleSettingsTab(w http.ResponseWriter, r *http.Request) { 60 + defer clearFlash(w) 60 61 settings, err := ui.getSettingsData(r.Context()) 61 62 if err != nil { 62 - http.Error(w, "Failed to load settings: "+err.Error(), http.StatusInternalServerError) 63 + renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load settings", err) 63 64 return 64 65 } 65 66 ··· 71 72 ui.renderTemplate(w, "partials/tab_settings.html", data) 72 73 } 73 74 74 - // handleSettingsUpdate processes settings updates 75 + // handleSettingsUpdate processes settings updates. Supports both plain-form 76 + // (flash-then-redirect) and htmx (HX-Trigger toast) paths so the settings 77 + // panel can stay in the SPA shell. 75 78 func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) { 76 79 ctx := r.Context() 80 + isHTMX := r.Header.Get("HX-Request") == "true" 81 + 82 + respond := func(category, msg string, status int) { 83 + if isHTMX { 84 + trigger := `{"toast":{"message":` + jsonString(msg) + `,"type":"` + category + `"}}` 85 + w.Header().Set("HX-Trigger", trigger) 86 + w.Header().Set("HX-Reswap", "none") 87 + if status == 0 { 88 + status = http.StatusNoContent 89 + } 90 + w.WriteHeader(status) 91 + return 92 + } 93 + setFlash(w, r, category, msg) 94 + http.Redirect(w, r, "/admin#settings", http.StatusFound) 95 + } 77 96 78 97 if err := r.ParseForm(); err != nil { 79 - setFlash(w, r, "error", "Invalid form data") 80 - http.Redirect(w, r, "/admin#settings", http.StatusFound) 98 + respond("error", "Invalid form data", http.StatusBadRequest) 81 99 return 82 100 } 83 101 ··· 89 107 // Validate successor DID format if provided 90 108 if successor != "" { 91 109 if !atproto.IsDID(successor) || (!strings.HasPrefix(successor, "did:web:") && !strings.HasPrefix(successor, "did:plc:")) { 92 - setFlash(w, r, "error", "Successor must be a valid did:web: or did:plc: DID") 93 - http.Redirect(w, r, "/admin#settings", http.StatusFound) 110 + respond("error", "Successor must be a valid did:web: or did:plc: DID", http.StatusBadRequest) 94 111 return 95 112 } 96 113 } ··· 99 116 _, captain, getErr := ui.pds.GetCaptainRecord(ctx) 100 117 if getErr != nil { 101 118 slog.Error("Failed to get captain record", "error", getErr) 102 - setFlash(w, r, "error", "Failed to read settings: "+getErr.Error()) 103 - http.Redirect(w, r, "/admin#settings", http.StatusFound) 119 + respond("error", "Couldn't read settings", http.StatusInternalServerError) 104 120 return 105 121 } 106 122 ··· 112 128 _, err := ui.pds.UpdateCaptainRecord(ctx, captain) 113 129 if err != nil { 114 130 slog.Error("Failed to update captain record", "error", err) 115 - setFlash(w, r, "error", "Failed to update settings: "+err.Error()) 116 - http.Redirect(w, r, "/admin#settings", http.StatusFound) 131 + respond("error", "Couldn't update settings", http.StatusInternalServerError) 117 132 return 118 133 } 119 134 ··· 133 148 134 149 // Show warning toast once, then suppress for 2 minutes 135 150 if _, err := r.Cookie("config_write_warned"); err != nil { 136 - setFlash(w, r, "warning", "Settings saved but config file is not writable — changes won't persist across restarts") 137 151 setConfigWriteWarnedCookie(w, r) 138 - http.Redirect(w, r, "/admin#settings", http.StatusFound) 152 + respond("warning", "Saved, but config file isn't writable — changes won't persist across restarts", 0) 139 153 return 140 154 } 141 155 } 142 156 } 143 157 144 - setFlash(w, r, "success", "Settings updated successfully") 145 - http.Redirect(w, r, "/admin#settings", http.StatusFound) 158 + respond("success", "Settings updated", 0) 159 + } 160 + 161 + // jsonString wraps a string in double quotes with JSON-safe escaping. 162 + // Used to build HX-Trigger header values without pulling in encoding/json. 163 + func jsonString(s string) string { 164 + b := make([]byte, 0, len(s)+2) 165 + b = append(b, '"') 166 + for i := 0; i < len(s); i++ { 167 + c := s[i] 168 + switch c { 169 + case '"', '\\': 170 + b = append(b, '\\', c) 171 + case '\n': 172 + b = append(b, '\\', 'n') 173 + case '\r': 174 + b = append(b, '\\', 'r') 175 + case '\t': 176 + b = append(b, '\\', 't') 177 + default: 178 + if c < 0x20 { 179 + b = append(b, '\\', 'u', '0', '0', 180 + "0123456789abcdef"[c>>4], 181 + "0123456789abcdef"[c&0xf]) 182 + } else { 183 + b = append(b, c) 184 + } 185 + } 186 + } 187 + b = append(b, '"') 188 + return string(b) 146 189 } 147 190 148 191 // writeConfigSettings updates the toggleable settings in the YAML config file. ··· 173 216 MaxAge: 120, // 2 minutes 174 217 HttpOnly: true, 175 218 Secure: secure, 176 - SameSite: http.SameSiteLaxMode, 219 + SameSite: http.SameSiteStrictMode, 177 220 }) 178 221 }
+1 -1
pkg/hold/admin/public/js/bundle.min.js
··· 1 - var Y=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var i=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return i&&(i==="*"||i.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let i=parseHTML(e);takeChildrenFor(r,i.body),r.title=i.title}else if(n==="body"){r=new DocumentFragment;let i=parseHTML(t);takeChildrenFor(r,i.body),r.title=i.title}else{let i=parseHTML('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content,r.title=i.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(i=>i.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n<e.length;n++)t.push(e[n]);return t}function forEach(e,t){if(e)for(let n=0;n<e.length;n++)t(e[n])}function isScrolledIntoView(e){let t=e.getBoundingClientRect(),n=t.top,r=t.bottom;return n<window.innerHeight&&r>=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let s=0,a=0;for(let l=0;l<t.length;l++){let u=t[l];if(u===","&&s===0){r.push(t.substring(a,l)),a=l+1;continue}u==="<"?s++:u==="/"&&l<t.length-1&&t[l+1]===">"&&s--}a<t.length&&r.push(t.substring(a))}let o=[],i=[];for(;r.length>0;){let s=normalizeSelector(r.shift()),a;s.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(s.slice(8))):s.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(s.slice(5))):s==="next"||s==="nextElementSibling"?a=asElement(e).nextElementSibling:s.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(s.slice(5)),!!n):s==="previous"||s==="previousElementSibling"?a=asElement(e).previousElementSibling:s.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(s.slice(9)),!!n):s==="document"?a=document:s==="window"?a=window:s==="body"?a=document.body:s==="root"?a=getRootNode(e,!!n):s==="host"?a=e.getRootNode().host:i.push(s),a&&o.push(a)}if(i.length>0){let s=i.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(s)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o<r.length;o++){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return i}},scanBackwardsQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=r.length-1;o>=0;o--){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return i}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let i=processEventArgs(e,t,n,r);i.target.addEventListener(i.event,i.listener,i.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let i=asElement(getClosestMatch(e,function(s){return s!==e&&hasAttribute(asElement(s),t)}));i&&r.push(...findAttributeTargets(i,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r<n.length;r++){let o=n[r];try{if(o.isInlineSwap(e))return!0}catch(i){logError(i)}}return e==="outerHTML"}function oobSwap(e,t,n,r){r=r||getDocument();let o="#"+CSS.escape(getRawAttribute(t,"id")),i="outerHTML";e==="true"||(e.indexOf(":")>0?(i=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):i=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let s=querySelectorAllExt(r,o,!1);return s.length?(forEach(s,function(a){let l,u=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(u),isInlineSwap(i,a)||(l=asParentNode(u));let f={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",f)&&(a=f.target,f.shouldSwap&&(handlePreservedElements(l),swapWithStyle(i,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(c){triggerEvent(c,"htmx:oobAfterSwap",f)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let i=o.replace("'","\\'"),s=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(s+"[id='"+i+"']");if(l&&l!==a){let u=r.cloneNode();cloneAttributes(r,l),n.tasks.push(function(){cloneAttributes(r,u)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n<e.length;)t=(t<<5)-t+e.charCodeAt(n++)|0;return t}function attributeHash(e){let t=0;for(let n=0;n<e.attributes.length;n++){let r=e.attributes[n];r.value&&(t=stringHash(r.name,t),t=stringHash(r.value,t))}return t}function deInitOnHandlers(e){let t=getInternalData(e);if(t.onHandlers){for(let n=0;n<t.onHandlers.length;n++){let r=t.onHandlers[n];removeEventListenerImpl(e,r.event,r.listener)}delete t.onHandlers}}function deInitNode(e){let t=getInternalData(e);t.timeout&&clearTimeout(t.timeout),t.listenerInfos&&forEach(t.listenerInfos,function(n){n.on&&removeEventListenerImpl(n.on,n.trigger,n.listener)}),deInitOnHandlers(e),forEach(Object.keys(t),function(n){n!=="firstInitCompleted"&&delete t[n]})}function cleanUpElement(e){triggerEvent(e,"htmx:beforeCleanupElement"),deInitNode(e),forEach(e.children,function(t){cleanUpElement(t)})}function swapOuterHTML(e,t,n){if(e.tagName==="BODY")return swapInnerHTML(e,t,n);let r,o=e.previousSibling,i=parentElt(e);if(i){for(insertNodesBefore(i,e,t,n),o==null?r=i.firstChild:r=o.nextSibling,n.elts=n.elts.filter(function(s){return s!==e});r&&r!==e;)r instanceof Element&&n.elts.push(r),r=r.nextSibling;cleanUpElement(e),e.remove()}}function swapAfterBegin(e,t,n){return insertNodesBefore(e,e.firstChild,t,n)}function swapBeforeBegin(e,t,n){return insertNodesBefore(parentElt(e),e,t,n)}function swapBeforeEnd(e,t,n){return insertNodesBefore(e,null,t,n)}function swapAfterEnd(e,t,n){return insertNodesBefore(parentElt(e),e.nextSibling,t,n)}function swapDelete(e){cleanUpElement(e);let t=parentElt(e);if(t)return t.removeChild(e)}function swapInnerHTML(e,t,n){let r=e.firstChild;if(insertNodesBefore(e,r,t,n),r){for(;r.nextSibling;)cleanUpElement(r.nextSibling),e.removeChild(r.nextSibling);cleanUpElement(r),e.removeChild(r)}}function swapWithStyle(e,t,n,r,o){switch(e){case"none":return;case"outerHTML":swapOuterHTML(n,r,o);return;case"afterbegin":swapAfterBegin(n,r,o);return;case"beforebegin":swapBeforeBegin(n,r,o);return;case"beforeend":swapBeforeEnd(n,r,o);return;case"afterend":swapAfterEnd(n,r,o);return;case"delete":swapDelete(n);return;default:var i=getExtensions(t);for(let s=0;s<i.length;s++){let a=i[s];try{let l=a.handleSwap(e,n,r,o);if(l){if(Array.isArray(l))for(let u=0;u<l.length;u++){let f=l[u];f.nodeType!==Node.TEXT_NODE&&f.nodeType!==Node.COMMENT_NODE&&o.tasks.push(makeAjaxLoadTask(f))}return}}catch(l){logError(l)}}e==="innerHTML"?swapInnerHTML(n,r,o):swapWithStyle(htmx.config.defaultSwapStyle,t,n,r,o)}}function findAndSwapOobElements(e,t,n){var r=findAll(e,"[hx-swap-oob], [data-hx-swap-oob]");return forEach(r,function(o){if(htmx.config.allowNestedOobSwaps||o.parentElement===null){let i=getAttributeValue(o,"hx-swap-oob");i!=null&&oobSwap(i,o,t,n)}else o.removeAttribute("hx-swap-oob"),o.removeAttribute("data-hx-swap-oob")}),r.length>0}function swap(e,t,n,r){r||(r={});let o=null,i=null,s=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let u=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),f=document.activeElement,c={};c={elt:f,start:f?f.selectionStart:null,end:f?f.selectionEnd:null};let d=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(d.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let E=r.selectOOB.split(",");for(let m=0;m<E.length;m++){let w=E[m].split(":",2),C=w[0].trim();C.indexOf("#")===0&&(C=C.substring(1));let x=w[1]||"true",p=h.querySelector("#"+C);p&&oobSwap(x,p,d,u)}}if(findAndSwapOobElements(h,d,u),forEach(findAll(h,"template"),function(E){E.content&&findAndSwapOobElements(E.content,d,u)&&E.remove()}),r.select){let E=getDocument().createDocumentFragment();forEach(h.querySelectorAll(r.select),function(m){E.appendChild(m)}),h=E}handlePreservedElements(h),swapWithStyle(n.swapStyle,r.contextElement,e,h,d),restorePreservedElements()}if(c.elt&&!bodyContains(c.elt)&&getRawAttribute(c.elt,"id")){let h=document.getElementById(getRawAttribute(c.elt,"id")),E={preventScroll:n.focusScroll!==void 0?!n.focusScroll:!htmx.config.defaultFocusScroll};if(h){if(c.start&&h.setSelectionRange)try{h.setSelectionRange(c.start,c.end)}catch{}h.focus(E)}}e.classList.remove(htmx.config.swappingClass),forEach(d.elts,function(h){h.classList&&h.classList.add(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSwap",r.eventInfo)}),maybeCall(r.afterSwapCallback),n.ignoreTitle||handleTitle(d.title);let y=function(){if(forEach(d.tasks,function(h){h.call()}),forEach(d.elts,function(h){h.classList&&h.classList.remove(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSettle",r.eventInfo)}),r.anchor){let h=asElement(resolveTarget("#"+r.anchor));h&&h.scrollIntoView({block:"start",behavior:"auto"})}updateScrollState(d.elts,n),maybeCall(r.afterSettleCallback),maybeCall(o)};n.settleDelay>0?getWindow().setTimeout(y,n.settleDelay):y()},a=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(a=n.transition);let l=r.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let u=new Promise(function(c,d){o=c,i=d}),f=s;s=function(){document.startViewTransition(function(){return f(),u})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(s,n.swapDelay):s()}catch(u){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(i),u}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let i in o)if(o.hasOwnProperty(i)){let s=o[i];isRawObject(s)?n=s.target!==void 0?s.target:n:s={value:s},triggerEvent(n,i,s)}}else{let o=r.split(",");for(let i=0;i<o.length;i++)triggerEvent(n,o[i].trim(),[])}}let WHITESPACE=/\s/,WHITESPACE_OR_COMMA=/[\s,]/,SYMBOL_START=/[_$a-zA-Z]/,SYMBOL_CONT=/[_$a-zA-Z0-9]/,STRINGISH_START=['"',"'","/"],NOT_WHITESPACE=/[^\s]/,COMBINED_SELECTOR_START=/[{(]/,COMBINED_SELECTOR_END=/[})]/;function tokenizeString(e){let t=[],n=0;for(;n<e.length;){if(SYMBOL_START.exec(e.charAt(n))){for(var r=n;SYMBOL_CONT.exec(e.charAt(n+1));)n++;t.push(e.substring(r,n+1))}else if(STRINGISH_START.indexOf(e.charAt(n))!==-1){let o=e.charAt(n);var r=n;for(n++;n<e.length&&e.charAt(n)!==o;)e.charAt(n)==="\\"&&n++,n++;t.push(e.substring(r,n+1))}else{let o=e.charAt(n);t.push(o)}n++}return t}function isPossibleRelativeReference(e,t,n){return SYMBOL_START.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function maybeGenerateConditional(e,t,n){if(t[0]==="["){t.shift();let r=1,o=" return (function("+n+"){ return (",i=null;for(;t.length>0;){let s=t[0];if(s==="]"){if(r--,r===0){i===null&&(o=o+"true"),t.shift(),o+=")})";try{let a=maybeEval(e,function(){return Function(o)()},function(){return!0});return a.source=o,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:o}),null}}}else s==="["&&r++;isPossibleRelativeReference(s,i,n)?o+="(("+n+"."+s+") ? ("+n+"."+s+") : (window."+s+"))":o=o+s,i=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let a=o.length,l=consumeUntil(o,/[,\[\s]/);if(l!=="")if(l==="every"){let u={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),u.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var i=maybeGenerateConditional(e,o,"event");i&&(u.eventFilter=i),r.push(u)}else{let u={trigger:l};var i=maybeGenerateConditional(e,o,"event");for(i&&(u.eventFilter=i),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let c=o.shift();if(c==="changed")u.changed=!0;else if(c==="once")u.once=!0;else if(c==="consume")u.consume=!0;else if(c==="delay"&&o[0]===":")o.shift(),u.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(c==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var s=consumeCSSSelector(o);else{var s=consumeUntil(o,WHITESPACE_OR_COMMA);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();let y=consumeCSSSelector(o);y.length>0&&(s+=" "+y)}}u.from=s}else c==="target"&&o[0]===":"?(o.shift(),u.target=consumeCSSSelector(o)):c==="throttle"&&o[0]===":"?(o.shift(),u.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):c==="queue"&&o[0]===":"?(o.shift(),u.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):c==="root"&&o[0]===":"?(o.shift(),u[c]=consumeCSSSelector(o)):c==="threshold"&&o[0]===":"?(o.shift(),u[c]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(u)}o.length===a&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let i=getRawAttribute(e,"method");r=i?i.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(i){addEventListener(e,function(s,a){let l=asElement(s);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,i,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let i=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:i}),!0}return!1}function addEventListener(e,t,n,r,o){let i=getInternalData(e),s;r.from?s=querySelectorAllExt(e,r.from):s=[e],r.changed&&("lastValue"in i||(i.lastValue=new WeakMap),s.forEach(function(a){i.lastValue.has(r)||i.lastValue.set(r,new WeakMap),i.lastValue.get(r).set(a,a.value)})),forEach(s,function(a){let l=function(u){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,u)||((o||shouldCancel(u,a))&&u.preventDefault(),maybeFilterEvent(r,e,u)))return;let f=getInternalData(u);if(f.triggerSpec=r,f.handledFor==null&&(f.handledFor=[]),f.handledFor.indexOf(e)<0){if(f.handledFor.push(e),r.consume&&u.stopPropagation(),r.target&&u.target&&!matches(asElement(u.target),r.target))return;if(r.once){if(i.triggeredOnce)return;i.triggeredOnce=!0}if(r.changed){let c=u.target,d=c.value,y=i.lastValue.get(r);if(y.has(c)&&y.get(c)===d)return;y.set(c,d)}if(i.delayed&&clearTimeout(i.delayed),i.throttle)return;r.throttle>0?i.throttle||(triggerEvent(e,"htmx:trigger"),t(e,u),i.throttle=getWindow().setTimeout(function(){i.throttle=null},r.throttle)):r.delay>0?i.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,u)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,u))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:l,on:a}),a.addEventListener(r.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let i=getAttributeValue(e,"hx-"+o);r=!0,t.path=i,t.verb=o,n.forEach(function(s){addTriggerHandler(e,s,t,function(a,l){let u=asElement(a);if(eltIsDisabled(u)){cleanUpElement(u);return}issueAjaxRequest(o,i,u,l)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(s){for(let a=0;a<s.length;a++)if(s[a].isIntersecting){triggerEvent(e,"intersect");break}},o).observe(asElement(e)),addEventListener(asElement(e),r,n,t)}else!n.firstInitCompleted&&t.trigger==="load"?maybeFilterEvent(t,e,makeEvent("load",{elt:e}))||loadImmediately(asElement(e),r,n,t.delay):t.pollInterval>0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r<n.length;r++){let o=n[r].name;if(startsWith(o,"hx-on:")||startsWith(o,"data-hx-on:")||startsWith(o,"hx-on-")||startsWith(o,"data-hx-on-"))return!0}return!1}let HX_ON_QUERY=new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function processHXOnRoot(e,t){shouldProcessHxOn(e)&&t.push(asElement(e));let n=HX_ON_QUERY.evaluate(e),r=null;for(;r=n.iterateNext();)t.push(asElement(r))}function findHxOnWildcardElements(e){let t=[];if(e instanceof DocumentFragment)for(let n of e.childNodes)processHXOnRoot(n,t);else processHXOnRoot(e,t);return t}function findElementsToProcess(e){if(e.querySelectorAll){let n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]",r=[];for(let i in extensions){let s=extensions[i];if(s.getSelectors){var t=s.getSelectors();t&&r.push(t)}}return e.querySelectorAll(VERB_SELECTOR+n+", form, [type='submit'], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(i=>", "+i).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,i=function(s){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,s))})};e.addEventListener(t,i),r.onHandlers.push({event:t,listener:i})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;t<e.attributes.length;t++){let n=e.attributes[t].name,r=e.attributes[t].value;if(startsWith(n,"hx-on")||startsWith(n,"data-hx-on")){let o=n.indexOf("-on")+3,i=n.slice(o,o+1);if(i==="-"||i===":"){let s=n.slice(o+1);startsWith(s,":")?s="htmx"+s:startsWith(s,"-")?s="htmx:"+s.slice(1):startsWith(s,"htmx-")&&(s="htmx:"+s.slice(5)),addHxOnEventHandler(e,s,r)}}}}function initNode(e){triggerEvent(e,"htmx:beforeProcessNode");let t=getInternalData(e),n=getTriggerSpecs(e);processVerbs(e,t,n)||(getClosestAttributeValue(e,"hx-boost")==="true"?boostElement(e,t,n):hasAttribute(e,"hx-trigger")&&n.forEach(function(o){addTriggerHandler(e,o,t,function(){})})),(e.tagName==="FORM"||getRawAttribute(e,"type")==="submit"&&hasAttribute(e,"form"))&&initButtonTracking(e),t.firstInitCompleted=!0,triggerEvent(e,"htmx:afterProcessNode")}function maybeDeInitAndHash(e){if(!(e instanceof Element))return!1;let t=getInternalData(e),n=attributeHash(e);return t.initHash!==n?(deInitNode(e),t.initHash=n,!0):!1}function processNode(e){if(e=resolveTarget(e),eltIsDisabled(e)){cleanUpElement(e);return}let t=[];maybeDeInitAndHash(e)&&t.push(e),forEach(findElementsToProcess(e),function(n){if(eltIsDisabled(n)){cleanUpElement(n);return}maybeDeInitAndHash(n)&&t.push(n)}),forEach(findHxOnWildcardElements(e),processHxOnWildcard),forEach(t,initNode)}function kebabEventName(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function makeEvent(e,t){return new CustomEvent(e,{bubbles:!0,cancelable:!0,composed:!0,detail:t})}function triggerErrorEvent(e,t,n){triggerEvent(e,t,mergeObjects({error:t},n))}function ignoreEventForLogging(e){return e==="htmx:afterProcessNode"}function withExtensions(e,t,n){forEach(getExtensions(e,[],n),function(r){try{t(r)}catch(o){logError(o)}})}function logError(e){console.error(e)}function triggerEvent(e,t,n){e=resolveTarget(e),n==null&&(n={}),n.elt=e;let r=makeEvent(t,n);htmx.logger&&!ignoreEventForLogging(t)&&htmx.logger(e,t,n),n.error&&(logError(n.error),triggerEvent(e,"htmx:error",{errorInfo:n}));let o=e.dispatchEvent(r),i=kebabEventName(t);if(o&&i!==t){let s=makeEvent(i,r.detail);o=o&&e.dispatchEvent(s)}return withExtensions(asElement(e),function(s){o=o&&s.onEvent(t,r)!==!1&&!r.defaultPrevented}),o}let currentPathForHistory;function setCurrentPathForHistory(e){currentPathForHistory=e,canAccessLocalStorage()&&sessionStorage.setItem("htmx-current-path-for-history",e)}setCurrentPathForHistory(location.pathname+location.search);function getHistoryElement(){return getDocument().querySelector("[hx-history-elt],[data-hx-history-elt]")||getDocument().body}function saveToHistoryCache(e,t){if(!canAccessLocalStorage())return;let n=cleanInnerHtmlForHistory(t),r=getDocument().title,o=window.scrollY;if(htmx.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}e=normalizePath(e);let i=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let a=0;a<i.length;a++)if(i[a].url===e){i.splice(a,1);break}let s={url:e,content:n,title:r,scroll:o};for(triggerEvent(getDocument().body,"htmx:historyItemCreated",{item:s,cache:i}),i.push(s);i.length>htmx.config.historyCacheSize;)i.shift();for(;i.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:i}),i.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n<t.length;n++)if(t[n].url===e)return t[n];return null}function cleanInnerHtmlForHistory(e){let t=htmx.config.requestClass,n=e.cloneNode(!0);return forEach(findAll(n,"."+t),function(r){removeClassFromElement(r,t)}),forEach(findAll(n,"[data-disabled-by-htmx]"),function(r){r.removeAttribute("disabled")}),n.innerHTML}function saveCurrentPageToHistory(){let e=getHistoryElement(),t=currentPathForHistory;canAccessLocalStorage()&&(t=sessionStorage.getItem("htmx-current-path-for-history")),t=t||location.pathname+location.search,getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]')||(triggerEvent(getDocument().body,"htmx:beforeHistorySave",{path:t,historyElt:e}),saveToHistoryCache(t,e)),htmx.config.historyEnabled&&history.replaceState({htmx:!0},getDocument().title,location.href)}function pushUrlIntoHistory(e){htmx.config.getCacheBusterParam&&(e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,""),(endsWith(e,"&")||endsWith(e,"?"))&&(e=e.slice(0,-1))),htmx.config.historyEnabled&&history.pushState({htmx:!0},"",e),setCurrentPathForHistory(e)}function replaceUrlInHistory(e){htmx.config.historyEnabled&&history.replaceState({htmx:!0},"",e),setCurrentPathForHistory(e)}function settleImmediately(e){forEach(e,function(t){t.call(void 0)})}function loadHistoryFromServer(e){let t=new XMLHttpRequest,n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0},r={path:e,xhr:t,historyElt:getHistoryElement(),swapSpec:n};t.open("GET",e,!0),htmx.config.historyRestoreAsHxRequest&&t.setRequestHeader("HX-Request","true"),t.setRequestHeader("HX-History-Restore-Request","true"),t.setRequestHeader("HX-Current-URL",location.href),t.onload=function(){this.status>=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;n<e.length;n++)if(e[n].isSameNode(t))return!0;return!1}function shouldInclude(e){let t=e;return t.name===""||t.name==null||t.disabled||closest(t,"fieldset[disabled]")||t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"?!1:t.type==="checkbox"||t.type==="radio"?t.checked:!0}function addValueToFormData(e,t,n){e!=null&&t!=null&&(Array.isArray(t)?t.forEach(function(r){n.append(e,r)}):n.append(e,t))}function removeValueFromFormData(e,t,n){if(e!=null&&t!=null){let r=n.getAll(e);Array.isArray(t)?r=r.filter(o=>t.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let i=getRawAttribute(r,"name");addValueToFormData(i,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(i){e.indexOf(i)>=0?removeValueFromFormData(i.name,getValueFromInput(i),t):e.push(i),o&&validateElement(i,n)}),new FormData(r).forEach(function(i,s){i instanceof File&&i.name===""||addValueToFormData(s,i,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,i=[],s=getInternalData(e);s.lastButtonClicked&&!bodyContains(s.lastButtonClicked)&&(s.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(s.lastButtonClicked&&(a=a&&s.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,i,getRelatedForm(e),a),processInputValue(n,r,i,e,a),s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let u=s.lastButtonClicked||e,f=getRawAttribute(u,"name");addValueToFormData(f,u.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(u){processInputValue(n,r,i,asElement(u),a),matches(u,"form")||forEach(asParentNode(u).querySelectorAll(INPUT_SELECTOR),function(f){processInputValue(n,r,i,f,a)})}),overrideFormData(r,o),{errors:i,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(i){r.append(o,i)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let s=splitOnWhitespace(n);if(s.length>0)for(let a=0;a<s.length;a++){let l=s[a];if(l.indexOf("swap:")===0)r.swapDelay=parseInterval(l.slice(5));else if(l.indexOf("settle:")===0)r.settleDelay=parseInterval(l.slice(7));else if(l.indexOf("transition:")===0)r.transition=l.slice(11)==="true";else if(l.indexOf("ignoreTitle:")===0)r.ignoreTitle=l.slice(12)==="true";else if(l.indexOf("scroll:")===0){var o=l.slice(7).split(":");let f=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=f,r.scrollTarget=i}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let c=o.pop();var i=o.length>0?o.join(":"):null;r.show=c,r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){let u=l.slice(13);r.focusScroll=u=="true"}else a==0?r.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let s=t.showTarget;t.showTarget==="window"&&(s="body"),o=asElement(querySelectorExt(n,s))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let i=getAttributeValue(e,t);if(i){let s=i.trim(),a=n;if(s==="unset")return null;s.indexOf("javascript:")===0?(s=s.slice(11),a=!0):s.indexOf("js:")===0&&(s=s.slice(3),a=!0),s.indexOf("{")!==0&&(s="{"+s+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+s+")").call(e,o):Function("return ("+s+")").call(e)},{}):l=parseJSON(s);for(let u in l)l.hasOwnProperty(u)&&r[u]==null&&(r[u]=l[u])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),i=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!i?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:i},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(i){r.push(i),e.append(t,i)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(i){e.append(t,i)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,i){return r[o]=i,e.delete(t),r.forEach(function(s){e.append(t,s)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,i){let s=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(g,b){s=g,a=b});n==null&&(n=getDocument().body);let u=o.handler||handleAjaxResponse,f=o.select||null;if(!bodyContains(n))return maybeCall(s),l;let c=o.targetOverride||asElement(getTarget(n));if(c==null||c==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let d=getInternalData(n),y=d.lastButtonClicked;if(y){let g=getRawAttribute(y,"formaction");g!=null&&(t=g);let b=getRawAttribute(y,"formmethod");if(b!=null)if(VERBS.includes(b.toLowerCase()))e=b;else return maybeCall(s),l}let h=getClosestAttributeValue(n,"hx-confirm");if(i===void 0&&triggerEvent(n,"htmx:confirm",{target:c,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(T){return issueAjaxRequest(e,t,n,r,o,!!T)},question:h})===!1)return maybeCall(s),l;let E=n,m=getClosestAttributeValue(n,"hx-sync"),w=null,C=!1;if(m){let g=m.split(":"),b=g[0].trim();if(b==="this"?E=findThisElement(n,"hx-sync"):E=asElement(querySelectorExt(n,b)),m=(g[1]||"drop").trim(),d=getInternalData(E),m==="drop"&&d.xhr&&d.abortable!==!0)return maybeCall(s),l;if(m==="abort"){if(d.xhr)return maybeCall(s),l;C=!0}else m==="replace"?triggerEvent(E,"htmx:abort"):m.indexOf("queue")===0&&(w=(m.split(" ")[1]||"last").trim())}if(d.xhr)if(d.abortable)triggerEvent(E,"htmx:abort");else{if(w==null){if(r){let g=getInternalData(r);g&&g.triggerSpec&&g.triggerSpec.queue&&(w=g.triggerSpec.queue)}w==null&&(w="last")}return d.queuedRequests==null&&(d.queuedRequests=[]),w==="first"&&d.queuedRequests.length===0?d.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="all"?d.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="last"&&(d.queuedRequests=[],d.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(s),l}let x=new XMLHttpRequest;d.xhr=x,d.abortable=C;let p=function(){d.xhr=null,d.abortable=!1,d.queuedRequests!=null&&d.queuedRequests.length>0&&d.queuedRequests.shift()()},V=getClosestAttributeValue(n,"hx-prompt");if(V){var P=prompt(V);if(P===null||!triggerEvent(n,"htmx:prompt",{prompt:P,target:c}))return maybeCall(s),p(),l}if(h&&!i&&!confirm(h))return maybeCall(s),p(),l;let H=getHeaders(n,c,P);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let k=getInputValues(n,e),D=k.errors,B=k.formData;o.values&&overrideFormData(B,formDataFromObject(o.values));let $=formDataFromObject(getExpressionVars(n,r)),q=overrideFormData(B,$),R=filterValues(q,n);htmx.config.getCacheBusterParam&&e==="get"&&R.set("org.htmx.cache-buster",getRawAttribute(c,"id")||"true"),(t==null||t==="")&&(t=location.href);let F=getValuesForElement(n,"hx-request"),U=getInternalData(n).boosted,I=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,A={boosted:U,useUrlParams:I,formData:R,parameters:formDataProxy(R),unfilteredFormData:q,unfilteredParameters:formDataProxy(q),headers:H,elt:n,target:c,verb:e,errors:D,withCredentials:o.credentials||F.credentials||htmx.config.withCredentials,timeout:o.timeout||F.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",A))return maybeCall(s),p(),l;if(t=A.path,e=A.verb,H=A.headers,R=formDataFromObject(A.parameters),D=A.errors,I=A.useUrlParams,D&&D.length>0)return triggerEvent(n,"htmx:validation:halted",A),maybeCall(s),p(),l;let _=t.split("#"),z=_[0],N=_[1],S=t;if(I&&(S=z,!R.keys().next().done&&(S.indexOf("?")<0?S+="?":S+="&",S+=urlEncode(R),N&&(S+="#"+N))),!verifyPath(n,S,A))return triggerErrorEvent(n,"htmx:invalidPath",A),maybeCall(a),p(),l;if(x.open(e.toUpperCase(),S,!0),x.overrideMimeType("text/html"),x.withCredentials=A.withCredentials,x.timeout=A.timeout,!F.noHeaders){for(let g in H)if(H.hasOwnProperty(g)){let b=H[g];safelySetHeaderValue(x,g,b)}}let v={xhr:x,target:c,requestConfig:A,etc:o,boosted:U,select:f,pathInfo:{requestPath:t,finalRequestPath:S,responsePath:null,anchor:N}};if(x.onload=function(){try{let g=hierarchyForElt(n);if(v.pathInfo.responsePath=getPathFromResponse(x),u(n,v),v.keepIndicators!==!0&&removeRequestIndicators(O,L),triggerEvent(n,"htmx:afterRequest",v),triggerEvent(n,"htmx:afterOnLoad",v),!bodyContains(n)){let b=null;for(;g.length>0&&b==null;){let T=g.shift();bodyContains(T)&&(b=T)}b&&(triggerEvent(b,"htmx:afterRequest",v),triggerEvent(b,"htmx:afterOnLoad",v))}maybeCall(s)}catch(g){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:g},v)),g}finally{p()}},x.onerror=function(){removeRequestIndicators(O,L),triggerErrorEvent(n,"htmx:afterRequest",v),triggerErrorEvent(n,"htmx:sendError",v),maybeCall(a),p()},x.onabort=function(){removeRequestIndicators(O,L),triggerErrorEvent(n,"htmx:afterRequest",v),triggerErrorEvent(n,"htmx:sendAbort",v),maybeCall(a),p()},x.ontimeout=function(){removeRequestIndicators(O,L),triggerErrorEvent(n,"htmx:afterRequest",v),triggerErrorEvent(n,"htmx:timeout",v),maybeCall(a),p()},!triggerEvent(n,"htmx:beforeRequest",v))return maybeCall(s),p(),l;var O=addRequestIndicatorClasses(n),L=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(g){forEach([x,x.upload],function(b){b.addEventListener(g,function(T){triggerEvent(n,"htmx:xhr:"+g,{lengthComputable:T.lengthComputable,loaded:T.loaded,total:T.total})})})}),triggerEvent(n,"htmx:beforeSend",v);let J=I?null:encodeParamsForBody(x,n,R);return x.send(J),l}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let i=t.pathInfo.finalRequestPath,s=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),u=getInternalData(e).boosted,f=null,c=null;return a?(f="push",c=a):l?(f="replace",c=l):u&&(f="push",c=s||i),c?c==="false"?{}:(c==="true"&&(c=s||i),t.pathInfo.anchor&&c.indexOf("#")===-1&&(c=c+"#"+t.pathInfo.anchor),{type:f,path:c}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t<htmx.config.responseHandling.length;t++){var n=htmx.config.responseHandling[t];if(codeMatches(n,e.status))return n}return{swap:!1}}function handleTitle(e){if(e){let t=find("title");t?t.textContent=e:window.document.title=e}}function resolveRetarget(e,t){if(t==="this")return e;let n=asElement(querySelectorExt(e,t));if(n==null)throw triggerErrorEvent(e,"htmx:targetError",{target:t}),new Error(`Invalid re-target ${t}`);return n}function handleAjaxResponse(e,t){let n=t.xhr,r=t.target,o=t.etc,i=t.select;if(!triggerEvent(e,"htmx:beforeOnLoad",t))return;if(hasHeader(n,/HX-Trigger:/i)&&handleTriggerHeader(n,"HX-Trigger",e),hasHeader(n,/HX-Location:/i)){let C=n.getResponseHeader("HX-Location");var s={};C.indexOf("{")===0&&(s=parseJSON(C),C=s.path,delete s.path),s.push=s.push||"true",ajaxHelper("get",C,s);return}let a=hasHeader(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(hasHeader(n,/HX-Redirect:/i)){t.keepIndicators=!0,htmx.location.href=n.getResponseHeader("HX-Redirect"),a&&htmx.location.reload();return}if(a){t.keepIndicators=!0,htmx.location.reload();return}let l=determineHistoryUpdates(e,t),u=resolveResponseHandling(n),f=u.swap,c=!!u.error,d=htmx.config.ignoreTitle||u.ignoreTitle,y=u.select;u.target&&(t.target=resolveRetarget(e,u.target));var h=o.swapOverride;h==null&&u.swapOverride&&(h=u.swapOverride),hasHeader(n,/HX-Retarget:/i)&&(t.target=resolveRetarget(e,n.getResponseHeader("HX-Retarget"))),hasHeader(n,/HX-Reswap:/i)&&(h=n.getResponseHeader("HX-Reswap"));var E=n.response,m=mergeObjects({shouldSwap:f,serverResponse:E,isError:c,ignoreTitle:d,selectOverride:y,swapOverride:h},t);if(!(u.event&&!triggerEvent(r,u.event,m))&&triggerEvent(r,"htmx:beforeSwap",m)){if(r=m.target,E=m.serverResponse,c=m.isError,d=m.ignoreTitle,y=m.selectOverride,h=m.swapOverride,t.target=r,t.failed=c,t.successful=!c,m.shouldSwap){n.status===286&&cancelPolling(e),withExtensions(e,function(p){E=p.transformResponse(E,n,e)}),l.type&&saveCurrentPageToHistory();var w=getSwapSpecification(e,h);w.hasOwnProperty("ignoreTitle")||(w.ignoreTitle=d),r.classList.add(htmx.config.swappingClass),i&&(y=i),hasHeader(n,/HX-Reselect:/i)&&(y=n.getResponseHeader("HX-Reselect"));let C=o.selectOOB||getClosestAttributeValue(e,"hx-select-oob"),x=getClosestAttributeValue(e,"hx-select");swap(r,E,w,{select:y==="unset"?null:y||x,selectOOB:C,eventInfo:t,anchor:t.pathInfo.anchor,contextElement:e,afterSwapCallback:function(){if(hasHeader(n,/HX-Trigger-After-Swap:/i)){let p=e;bodyContains(e)||(p=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Swap",p)}},afterSettleCallback:function(){if(hasHeader(n,/HX-Trigger-After-Settle:/i)){let p=e;bodyContains(e)||(p=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Settle",p)}},beforeSwapCallback:function(){l.type&&(triggerEvent(getDocument().body,"htmx:beforeHistoryUpdate",mergeObjects({history:l},t)),l.type==="push"?(pushUrlIntoHistory(l.path),triggerEvent(getDocument().body,"htmx:pushedIntoHistory",{path:l.path})):(replaceUrlInHistory(l.path),triggerEvent(getDocument().body,"htmx:replacedInHistory",{path:l.path})))}})}c&&triggerErrorEvent(e,"htmx:responseError",mergeObjects({error:"Response Status Error Code "+n.status+" from "+t.pathInfo.requestPath},t))}}let extensions={};function extensionBase(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,n,r){return!1},encodeParameters:function(e,t,n){return null}}}function defineExtension(e,t){t.init&&t.init(internalAPI),extensions[e]=mergeObjects(extensionBase(),t)}function removeExtension(e){delete extensions[e]}function getExtensions(e,t,n){if(t==null&&(t=[]),e==null)return t;n==null&&(n=[]);let r=getAttributeValue(e,"hx-ext");return r&&forEach(r.split(","),function(o){if(o=o.replace(/ /g,""),o.slice(0,7)=="ignore:"){n.push(o.slice(7));return}if(n.indexOf(o)<0){let i=extensions[o];i&&t.indexOf(i)<0&&t.push(i)}}),getExtensions(asElement(parentElt(e)),t,n)}var isReady=!1;getDocument().addEventListener("DOMContentLoaded",function(){isReady=!0});function ready(e){isReady||getDocument().readyState==="complete"?e():getDocument().addEventListener("DOMContentLoaded",e)}function insertIndicatorStyles(){if(htmx.config.includeIndicatorStyles!==!1){let e=htmx.config.inlineStyleNonce?` nonce="${htmx.config.inlineStyleNonce}"`:"",t=htmx.config.indicatorClass,n=htmx.config.requestClass;getDocument().head.insertAdjacentHTML("beforeend",`<style${e}>.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}</style>`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,i=getInternalData(o);i&&i.xhr&&i.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),W=Y;window.htmx=W;function j(){return localStorage.getItem("hold-admin-theme")||"system"}function G(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function M(){let e=j(),n=G(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),Q(e)}function X(e){localStorage.setItem("hold-admin-theme",e),M(),K()}function Q(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/admin/public/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e,o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function K(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{j()==="system"&&M()});function Z(){let e=document.getElementById("did"),t=document.getElementById("lookup-btn"),n=document.getElementById("handle-result");if(!e||!t||!n)return;async function r(){let o=e.value.trim();if(!o.startsWith("did:")){n.innerHTML='<span class="text-error">Invalid DID format</span>';return}n.innerHTML='<span class="text-base-content/50 italic">Looking up...</span>';try{let i;if(o.startsWith("did:plc:"))i=`https://plc.directory/${o}`;else if(o.startsWith("did:web:"))i=`https://${o.replace("did:web:","").replace(/%3A/g,":")}/.well-known/did.json`;else{n.innerHTML='<span class="text-error">Unsupported DID method</span>';return}let s=await fetch(i);if(!s.ok)throw new Error("DID not found");let u=((await s.json()).alsoKnownAs||[]).find(f=>f.startsWith("at://"));if(u){let f=u.replace("at://","");n.innerHTML=`<span class="text-success flex items-center gap-1"><svg class="icon size-4" aria-hidden="true"><use href="/admin/public/icons.svg#check-circle"></use></svg> <strong>${f}</strong></span>`}else n.innerHTML='<span class="text-warning">No handle found</span>'}catch(i){n.innerHTML=`<span class="text-error">Lookup failed: ${i.message}</span>`}}t.addEventListener("click",r),e.addEventListener("blur",function(){this.value.startsWith("did:")&&this.value.length>10&&r()})}document.addEventListener("DOMContentLoaded",()=>{M(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{X(t.dataset.value)})})}),Z()});window.setTheme=X; 1 + var Q=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var i=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return i&&(i==="*"||i.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let i=parseHTML(e);takeChildrenFor(r,i.body),r.title=i.title}else if(n==="body"){r=new DocumentFragment;let i=parseHTML(t);takeChildrenFor(r,i.body),r.title=i.title}else{let i=parseHTML('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content,r.title=i.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(i=>i.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n<e.length;n++)t.push(e[n]);return t}function forEach(e,t){if(e)for(let n=0;n<e.length;n++)t(e[n])}function isScrolledIntoView(e){let t=e.getBoundingClientRect(),n=t.top,r=t.bottom;return n<window.innerHeight&&r>=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let s=0,l=0;for(let a=0;a<t.length;a++){let c=t[a];if(c===","&&s===0){r.push(t.substring(l,a)),l=a+1;continue}c==="<"?s++:c==="/"&&a<t.length-1&&t[a+1]===">"&&s--}l<t.length&&r.push(t.substring(l))}let o=[],i=[];for(;r.length>0;){let s=normalizeSelector(r.shift()),l;s.indexOf("closest ")===0?l=closest(asElement(e),normalizeSelector(s.slice(8))):s.indexOf("find ")===0?l=find(asParentNode(e),normalizeSelector(s.slice(5))):s==="next"||s==="nextElementSibling"?l=asElement(e).nextElementSibling:s.indexOf("next ")===0?l=scanForwardQuery(e,normalizeSelector(s.slice(5)),!!n):s==="previous"||s==="previousElementSibling"?l=asElement(e).previousElementSibling:s.indexOf("previous ")===0?l=scanBackwardsQuery(e,normalizeSelector(s.slice(9)),!!n):s==="document"?l=document:s==="window"?l=window:s==="body"?l=document.body:s==="root"?l=getRootNode(e,!!n):s==="host"?l=e.getRootNode().host:i.push(s),l&&o.push(l)}if(i.length>0){let s=i.join(","),l=asParentNode(getRootNode(e,!!n));o.push(...toArray(l.querySelectorAll(s)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o<r.length;o++){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return i}},scanBackwardsQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=r.length-1;o>=0;o--){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return i}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let i=processEventArgs(e,t,n,r);i.target.addEventListener(i.event,i.listener,i.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let i=asElement(getClosestMatch(e,function(s){return s!==e&&hasAttribute(asElement(s),t)}));i&&r.push(...findAttributeTargets(i,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r<n.length;r++){let o=n[r];try{if(o.isInlineSwap(e))return!0}catch(i){logError(i)}}return e==="outerHTML"}function oobSwap(e,t,n,r){r=r||getDocument();let o="#"+CSS.escape(getRawAttribute(t,"id")),i="outerHTML";e==="true"||(e.indexOf(":")>0?(i=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):i=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let s=querySelectorAllExt(r,o,!1);return s.length?(forEach(s,function(l){let a,c=t.cloneNode(!0);a=getDocument().createDocumentFragment(),a.appendChild(c),isInlineSwap(i,l)||(a=asParentNode(c));let f={shouldSwap:!0,target:l,fragment:a};triggerEvent(l,"htmx:oobBeforeSwap",f)&&(l=f.target,f.shouldSwap&&(handlePreservedElements(a),swapWithStyle(i,l,l,a,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",f)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let i=o.replace("'","\\'"),s=r.tagName.replace(":","\\:"),l=asParentNode(e),a=l&&l.querySelector(s+"[id='"+i+"']");if(a&&a!==l){let c=r.cloneNode();cloneAttributes(r,a),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n<e.length;)t=(t<<5)-t+e.charCodeAt(n++)|0;return t}function attributeHash(e){let t=0;for(let n=0;n<e.attributes.length;n++){let r=e.attributes[n];r.value&&(t=stringHash(r.name,t),t=stringHash(r.value,t))}return t}function deInitOnHandlers(e){let t=getInternalData(e);if(t.onHandlers){for(let n=0;n<t.onHandlers.length;n++){let r=t.onHandlers[n];removeEventListenerImpl(e,r.event,r.listener)}delete t.onHandlers}}function deInitNode(e){let t=getInternalData(e);t.timeout&&clearTimeout(t.timeout),t.listenerInfos&&forEach(t.listenerInfos,function(n){n.on&&removeEventListenerImpl(n.on,n.trigger,n.listener)}),deInitOnHandlers(e),forEach(Object.keys(t),function(n){n!=="firstInitCompleted"&&delete t[n]})}function cleanUpElement(e){triggerEvent(e,"htmx:beforeCleanupElement"),deInitNode(e),forEach(e.children,function(t){cleanUpElement(t)})}function swapOuterHTML(e,t,n){if(e.tagName==="BODY")return swapInnerHTML(e,t,n);let r,o=e.previousSibling,i=parentElt(e);if(i){for(insertNodesBefore(i,e,t,n),o==null?r=i.firstChild:r=o.nextSibling,n.elts=n.elts.filter(function(s){return s!==e});r&&r!==e;)r instanceof Element&&n.elts.push(r),r=r.nextSibling;cleanUpElement(e),e.remove()}}function swapAfterBegin(e,t,n){return insertNodesBefore(e,e.firstChild,t,n)}function swapBeforeBegin(e,t,n){return insertNodesBefore(parentElt(e),e,t,n)}function swapBeforeEnd(e,t,n){return insertNodesBefore(e,null,t,n)}function swapAfterEnd(e,t,n){return insertNodesBefore(parentElt(e),e.nextSibling,t,n)}function swapDelete(e){cleanUpElement(e);let t=parentElt(e);if(t)return t.removeChild(e)}function swapInnerHTML(e,t,n){let r=e.firstChild;if(insertNodesBefore(e,r,t,n),r){for(;r.nextSibling;)cleanUpElement(r.nextSibling),e.removeChild(r.nextSibling);cleanUpElement(r),e.removeChild(r)}}function swapWithStyle(e,t,n,r,o){switch(e){case"none":return;case"outerHTML":swapOuterHTML(n,r,o);return;case"afterbegin":swapAfterBegin(n,r,o);return;case"beforebegin":swapBeforeBegin(n,r,o);return;case"beforeend":swapBeforeEnd(n,r,o);return;case"afterend":swapAfterEnd(n,r,o);return;case"delete":swapDelete(n);return;default:var i=getExtensions(t);for(let s=0;s<i.length;s++){let l=i[s];try{let a=l.handleSwap(e,n,r,o);if(a){if(Array.isArray(a))for(let c=0;c<a.length;c++){let f=a[c];f.nodeType!==Node.TEXT_NODE&&f.nodeType!==Node.COMMENT_NODE&&o.tasks.push(makeAjaxLoadTask(f))}return}}catch(a){logError(a)}}e==="innerHTML"?swapInnerHTML(n,r,o):swapWithStyle(htmx.config.defaultSwapStyle,t,n,r,o)}}function findAndSwapOobElements(e,t,n){var r=findAll(e,"[hx-swap-oob], [data-hx-swap-oob]");return forEach(r,function(o){if(htmx.config.allowNestedOobSwaps||o.parentElement===null){let i=getAttributeValue(o,"hx-swap-oob");i!=null&&oobSwap(i,o,t,n)}else o.removeAttribute("hx-swap-oob"),o.removeAttribute("data-hx-swap-oob")}),r.length>0}function swap(e,t,n,r){r||(r={});let o=null,i=null,s=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),f=document.activeElement,u={};u={elt:f,start:f?f.selectionStart:null,end:f?f.selectionEnd:null};let d=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(d.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let E=r.selectOOB.split(",");for(let g=0;g<E.length;g++){let w=E[g].split(":",2),C=w[0].trim();C.indexOf("#")===0&&(C=C.substring(1));let x=w[1]||"true",p=h.querySelector("#"+C);p&&oobSwap(x,p,d,c)}}if(findAndSwapOobElements(h,d,c),forEach(findAll(h,"template"),function(E){E.content&&findAndSwapOobElements(E.content,d,c)&&E.remove()}),r.select){let E=getDocument().createDocumentFragment();forEach(h.querySelectorAll(r.select),function(g){E.appendChild(g)}),h=E}handlePreservedElements(h),swapWithStyle(n.swapStyle,r.contextElement,e,h,d),restorePreservedElements()}if(u.elt&&!bodyContains(u.elt)&&getRawAttribute(u.elt,"id")){let h=document.getElementById(getRawAttribute(u.elt,"id")),E={preventScroll:n.focusScroll!==void 0?!n.focusScroll:!htmx.config.defaultFocusScroll};if(h){if(u.start&&h.setSelectionRange)try{h.setSelectionRange(u.start,u.end)}catch{}h.focus(E)}}e.classList.remove(htmx.config.swappingClass),forEach(d.elts,function(h){h.classList&&h.classList.add(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSwap",r.eventInfo)}),maybeCall(r.afterSwapCallback),n.ignoreTitle||handleTitle(d.title);let y=function(){if(forEach(d.tasks,function(h){h.call()}),forEach(d.elts,function(h){h.classList&&h.classList.remove(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSettle",r.eventInfo)}),r.anchor){let h=asElement(resolveTarget("#"+r.anchor));h&&h.scrollIntoView({block:"start",behavior:"auto"})}updateScrollState(d.elts,n),maybeCall(r.afterSettleCallback),maybeCall(o)};n.settleDelay>0?getWindow().setTimeout(y,n.settleDelay):y()},l=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(l=n.transition);let a=r.contextElement||getDocument();if(l&&triggerEvent(a,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(u,d){o=u,i=d}),f=s;s=function(){document.startViewTransition(function(){return f(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(s,n.swapDelay):s()}catch(c){throw triggerErrorEvent(a,"htmx:swapError",r.eventInfo),maybeCall(i),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let i in o)if(o.hasOwnProperty(i)){let s=o[i];isRawObject(s)?n=s.target!==void 0?s.target:n:s={value:s},triggerEvent(n,i,s)}}else{let o=r.split(",");for(let i=0;i<o.length;i++)triggerEvent(n,o[i].trim(),[])}}let WHITESPACE=/\s/,WHITESPACE_OR_COMMA=/[\s,]/,SYMBOL_START=/[_$a-zA-Z]/,SYMBOL_CONT=/[_$a-zA-Z0-9]/,STRINGISH_START=['"',"'","/"],NOT_WHITESPACE=/[^\s]/,COMBINED_SELECTOR_START=/[{(]/,COMBINED_SELECTOR_END=/[})]/;function tokenizeString(e){let t=[],n=0;for(;n<e.length;){if(SYMBOL_START.exec(e.charAt(n))){for(var r=n;SYMBOL_CONT.exec(e.charAt(n+1));)n++;t.push(e.substring(r,n+1))}else if(STRINGISH_START.indexOf(e.charAt(n))!==-1){let o=e.charAt(n);var r=n;for(n++;n<e.length&&e.charAt(n)!==o;)e.charAt(n)==="\\"&&n++,n++;t.push(e.substring(r,n+1))}else{let o=e.charAt(n);t.push(o)}n++}return t}function isPossibleRelativeReference(e,t,n){return SYMBOL_START.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function maybeGenerateConditional(e,t,n){if(t[0]==="["){t.shift();let r=1,o=" return (function("+n+"){ return (",i=null;for(;t.length>0;){let s=t[0];if(s==="]"){if(r--,r===0){i===null&&(o=o+"true"),t.shift(),o+=")})";try{let l=maybeEval(e,function(){return Function(o)()},function(){return!0});return l.source=o,l}catch(l){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:l,source:o}),null}}}else s==="["&&r++;isPossibleRelativeReference(s,i,n)?o+="(("+n+"."+s+") ? ("+n+"."+s+") : (window."+s+"))":o=o+s,i=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let l=o.length,a=consumeUntil(o,/[,\[\s]/);if(a!=="")if(a==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var i=maybeGenerateConditional(e,o,"event");i&&(c.eventFilter=i),r.push(c)}else{let c={trigger:a};var i=maybeGenerateConditional(e,o,"event");for(i&&(c.eventFilter=i),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let u=o.shift();if(u==="changed")c.changed=!0;else if(u==="once")c.once=!0;else if(u==="consume")c.consume=!0;else if(u==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(u==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var s=consumeCSSSelector(o);else{var s=consumeUntil(o,WHITESPACE_OR_COMMA);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();let y=consumeCSSSelector(o);y.length>0&&(s+=" "+y)}}c.from=s}else u==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):u==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):u==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):u==="root"&&o[0]===":"?(o.shift(),c[u]=consumeCSSSelector(o)):u==="threshold"&&o[0]===":"?(o.shift(),c[u]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===l&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let i=getRawAttribute(e,"method");r=i?i.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(i){addEventListener(e,function(s,l){let a=asElement(s);if(eltIsDisabled(a)){cleanUpElement(a);return}issueAjaxRequest(r,o,a,l)},t,i,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let i=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:i}),!0}return!1}function addEventListener(e,t,n,r,o){let i=getInternalData(e),s;r.from?s=querySelectorAllExt(e,r.from):s=[e],r.changed&&("lastValue"in i||(i.lastValue=new WeakMap),s.forEach(function(l){i.lastValue.has(r)||i.lastValue.set(r,new WeakMap),i.lastValue.get(r).set(l,l.value)})),forEach(s,function(l){let a=function(c){if(!bodyContains(e)){l.removeEventListener(r.trigger,a);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,l))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let f=getInternalData(c);if(f.triggerSpec=r,f.handledFor==null&&(f.handledFor=[]),f.handledFor.indexOf(e)<0){if(f.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(i.triggeredOnce)return;i.triggeredOnce=!0}if(r.changed){let u=c.target,d=u.value,y=i.lastValue.get(r);if(y.has(u)&&y.get(u)===d)return;y.set(u,d)}if(i.delayed&&clearTimeout(i.delayed),i.throttle)return;r.throttle>0?i.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),i.throttle=getWindow().setTimeout(function(){i.throttle=null},r.throttle)):r.delay>0?i.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:a,on:l}),l.addEventListener(r.trigger,a)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let i=getAttributeValue(e,"hx-"+o);r=!0,t.path=i,t.verb=o,n.forEach(function(s){addTriggerHandler(e,s,t,function(l,a){let c=asElement(l);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,i,c,a)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(s){for(let l=0;l<s.length;l++)if(s[l].isIntersecting){triggerEvent(e,"intersect");break}},o).observe(asElement(e)),addEventListener(asElement(e),r,n,t)}else!n.firstInitCompleted&&t.trigger==="load"?maybeFilterEvent(t,e,makeEvent("load",{elt:e}))||loadImmediately(asElement(e),r,n,t.delay):t.pollInterval>0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r<n.length;r++){let o=n[r].name;if(startsWith(o,"hx-on:")||startsWith(o,"data-hx-on:")||startsWith(o,"hx-on-")||startsWith(o,"data-hx-on-"))return!0}return!1}let HX_ON_QUERY=new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function processHXOnRoot(e,t){shouldProcessHxOn(e)&&t.push(asElement(e));let n=HX_ON_QUERY.evaluate(e),r=null;for(;r=n.iterateNext();)t.push(asElement(r))}function findHxOnWildcardElements(e){let t=[];if(e instanceof DocumentFragment)for(let n of e.childNodes)processHXOnRoot(n,t);else processHXOnRoot(e,t);return t}function findElementsToProcess(e){if(e.querySelectorAll){let n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]",r=[];for(let i in extensions){let s=extensions[i];if(s.getSelectors){var t=s.getSelectors();t&&r.push(t)}}return e.querySelectorAll(VERB_SELECTOR+n+", form, [type='submit'], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(i=>", "+i).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,i=function(s){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,s))})};e.addEventListener(t,i),r.onHandlers.push({event:t,listener:i})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;t<e.attributes.length;t++){let n=e.attributes[t].name,r=e.attributes[t].value;if(startsWith(n,"hx-on")||startsWith(n,"data-hx-on")){let o=n.indexOf("-on")+3,i=n.slice(o,o+1);if(i==="-"||i===":"){let s=n.slice(o+1);startsWith(s,":")?s="htmx"+s:startsWith(s,"-")?s="htmx:"+s.slice(1):startsWith(s,"htmx-")&&(s="htmx:"+s.slice(5)),addHxOnEventHandler(e,s,r)}}}}function initNode(e){triggerEvent(e,"htmx:beforeProcessNode");let t=getInternalData(e),n=getTriggerSpecs(e);processVerbs(e,t,n)||(getClosestAttributeValue(e,"hx-boost")==="true"?boostElement(e,t,n):hasAttribute(e,"hx-trigger")&&n.forEach(function(o){addTriggerHandler(e,o,t,function(){})})),(e.tagName==="FORM"||getRawAttribute(e,"type")==="submit"&&hasAttribute(e,"form"))&&initButtonTracking(e),t.firstInitCompleted=!0,triggerEvent(e,"htmx:afterProcessNode")}function maybeDeInitAndHash(e){if(!(e instanceof Element))return!1;let t=getInternalData(e),n=attributeHash(e);return t.initHash!==n?(deInitNode(e),t.initHash=n,!0):!1}function processNode(e){if(e=resolveTarget(e),eltIsDisabled(e)){cleanUpElement(e);return}let t=[];maybeDeInitAndHash(e)&&t.push(e),forEach(findElementsToProcess(e),function(n){if(eltIsDisabled(n)){cleanUpElement(n);return}maybeDeInitAndHash(n)&&t.push(n)}),forEach(findHxOnWildcardElements(e),processHxOnWildcard),forEach(t,initNode)}function kebabEventName(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function makeEvent(e,t){return new CustomEvent(e,{bubbles:!0,cancelable:!0,composed:!0,detail:t})}function triggerErrorEvent(e,t,n){triggerEvent(e,t,mergeObjects({error:t},n))}function ignoreEventForLogging(e){return e==="htmx:afterProcessNode"}function withExtensions(e,t,n){forEach(getExtensions(e,[],n),function(r){try{t(r)}catch(o){logError(o)}})}function logError(e){console.error(e)}function triggerEvent(e,t,n){e=resolveTarget(e),n==null&&(n={}),n.elt=e;let r=makeEvent(t,n);htmx.logger&&!ignoreEventForLogging(t)&&htmx.logger(e,t,n),n.error&&(logError(n.error),triggerEvent(e,"htmx:error",{errorInfo:n}));let o=e.dispatchEvent(r),i=kebabEventName(t);if(o&&i!==t){let s=makeEvent(i,r.detail);o=o&&e.dispatchEvent(s)}return withExtensions(asElement(e),function(s){o=o&&s.onEvent(t,r)!==!1&&!r.defaultPrevented}),o}let currentPathForHistory;function setCurrentPathForHistory(e){currentPathForHistory=e,canAccessLocalStorage()&&sessionStorage.setItem("htmx-current-path-for-history",e)}setCurrentPathForHistory(location.pathname+location.search);function getHistoryElement(){return getDocument().querySelector("[hx-history-elt],[data-hx-history-elt]")||getDocument().body}function saveToHistoryCache(e,t){if(!canAccessLocalStorage())return;let n=cleanInnerHtmlForHistory(t),r=getDocument().title,o=window.scrollY;if(htmx.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}e=normalizePath(e);let i=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let l=0;l<i.length;l++)if(i[l].url===e){i.splice(l,1);break}let s={url:e,content:n,title:r,scroll:o};for(triggerEvent(getDocument().body,"htmx:historyItemCreated",{item:s,cache:i}),i.push(s);i.length>htmx.config.historyCacheSize;)i.shift();for(;i.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(l){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:l,cache:i}),i.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n<t.length;n++)if(t[n].url===e)return t[n];return null}function cleanInnerHtmlForHistory(e){let t=htmx.config.requestClass,n=e.cloneNode(!0);return forEach(findAll(n,"."+t),function(r){removeClassFromElement(r,t)}),forEach(findAll(n,"[data-disabled-by-htmx]"),function(r){r.removeAttribute("disabled")}),n.innerHTML}function saveCurrentPageToHistory(){let e=getHistoryElement(),t=currentPathForHistory;canAccessLocalStorage()&&(t=sessionStorage.getItem("htmx-current-path-for-history")),t=t||location.pathname+location.search,getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]')||(triggerEvent(getDocument().body,"htmx:beforeHistorySave",{path:t,historyElt:e}),saveToHistoryCache(t,e)),htmx.config.historyEnabled&&history.replaceState({htmx:!0},getDocument().title,location.href)}function pushUrlIntoHistory(e){htmx.config.getCacheBusterParam&&(e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,""),(endsWith(e,"&")||endsWith(e,"?"))&&(e=e.slice(0,-1))),htmx.config.historyEnabled&&history.pushState({htmx:!0},"",e),setCurrentPathForHistory(e)}function replaceUrlInHistory(e){htmx.config.historyEnabled&&history.replaceState({htmx:!0},"",e),setCurrentPathForHistory(e)}function settleImmediately(e){forEach(e,function(t){t.call(void 0)})}function loadHistoryFromServer(e){let t=new XMLHttpRequest,n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0},r={path:e,xhr:t,historyElt:getHistoryElement(),swapSpec:n};t.open("GET",e,!0),htmx.config.historyRestoreAsHxRequest&&t.setRequestHeader("HX-Request","true"),t.setRequestHeader("HX-History-Restore-Request","true"),t.setRequestHeader("HX-Current-URL",location.href),t.onload=function(){this.status>=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;n<e.length;n++)if(e[n].isSameNode(t))return!0;return!1}function shouldInclude(e){let t=e;return t.name===""||t.name==null||t.disabled||closest(t,"fieldset[disabled]")||t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"?!1:t.type==="checkbox"||t.type==="radio"?t.checked:!0}function addValueToFormData(e,t,n){e!=null&&t!=null&&(Array.isArray(t)?t.forEach(function(r){n.append(e,r)}):n.append(e,t))}function removeValueFromFormData(e,t,n){if(e!=null&&t!=null){let r=n.getAll(e);Array.isArray(t)?r=r.filter(o=>t.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let i=getRawAttribute(r,"name");addValueToFormData(i,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(i){e.indexOf(i)>=0?removeValueFromFormData(i.name,getValueFromInput(i),t):e.push(i),o&&validateElement(i,n)}),new FormData(r).forEach(function(i,s){i instanceof File&&i.name===""||addValueToFormData(s,i,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,i=[],s=getInternalData(e);s.lastButtonClicked&&!bodyContains(s.lastButtonClicked)&&(s.lastButtonClicked=null);let l=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(s.lastButtonClicked&&(l=l&&s.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,i,getRelatedForm(e),l),processInputValue(n,r,i,e,l),s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=s.lastButtonClicked||e,f=getRawAttribute(c,"name");addValueToFormData(f,c.value,o)}let a=findAttributeTargets(e,"hx-include");return forEach(a,function(c){processInputValue(n,r,i,asElement(c),l),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(f){processInputValue(n,r,i,f,l)})}),overrideFormData(r,o),{errors:i,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(i){r.append(o,i)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let s=splitOnWhitespace(n);if(s.length>0)for(let l=0;l<s.length;l++){let a=s[l];if(a.indexOf("swap:")===0)r.swapDelay=parseInterval(a.slice(5));else if(a.indexOf("settle:")===0)r.settleDelay=parseInterval(a.slice(7));else if(a.indexOf("transition:")===0)r.transition=a.slice(11)==="true";else if(a.indexOf("ignoreTitle:")===0)r.ignoreTitle=a.slice(12)==="true";else if(a.indexOf("scroll:")===0){var o=a.slice(7).split(":");let f=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=f,r.scrollTarget=i}else if(a.indexOf("show:")===0){var o=a.slice(5).split(":");let u=o.pop();var i=o.length>0?o.join(":"):null;r.show=u,r.showTarget=i}else if(a.indexOf("focus-scroll:")===0){let c=a.slice(13);r.focusScroll=c=="true"}else l==0?r.swapStyle=a:logError("Unknown modifier in hx-swap: "+a)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let s=t.showTarget;t.showTarget==="window"&&(s="body"),o=asElement(querySelectorExt(n,s))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let i=getAttributeValue(e,t);if(i){let s=i.trim(),l=n;if(s==="unset")return null;s.indexOf("javascript:")===0?(s=s.slice(11),l=!0):s.indexOf("js:")===0&&(s=s.slice(3),l=!0),s.indexOf("{")!==0&&(s="{"+s+"}");let a;l?a=maybeEval(e,function(){return o?Function("event","return ("+s+")").call(e,o):Function("return ("+s+")").call(e)},{}):a=parseJSON(s);for(let c in a)a.hasOwnProperty(c)&&r[c]==null&&(r[c]=a[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),i=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!i?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:i},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(i){r.push(i),e.append(t,i)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(i){e.append(t,i)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,i){return r[o]=i,e.delete(t),r.forEach(function(s){e.append(t,s)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,i){let s=null,l=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var a=new Promise(function(m,b){s=m,l=b});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,f=o.select||null;if(!bodyContains(n))return maybeCall(s),a;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(l),a;let d=getInternalData(n),y=d.lastButtonClicked;if(y){let m=getRawAttribute(y,"formaction");m!=null&&(t=m);let b=getRawAttribute(y,"formmethod");if(b!=null)if(VERBS.includes(b.toLowerCase()))e=b;else return maybeCall(s),a}let h=getClosestAttributeValue(n,"hx-confirm");if(i===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(T){return issueAjaxRequest(e,t,n,r,o,!!T)},question:h})===!1)return maybeCall(s),a;let E=n,g=getClosestAttributeValue(n,"hx-sync"),w=null,C=!1;if(g){let m=g.split(":"),b=m[0].trim();if(b==="this"?E=findThisElement(n,"hx-sync"):E=asElement(querySelectorExt(n,b)),g=(m[1]||"drop").trim(),d=getInternalData(E),g==="drop"&&d.xhr&&d.abortable!==!0)return maybeCall(s),a;if(g==="abort"){if(d.xhr)return maybeCall(s),a;C=!0}else g==="replace"?triggerEvent(E,"htmx:abort"):g.indexOf("queue")===0&&(w=(g.split(" ")[1]||"last").trim())}if(d.xhr)if(d.abortable)triggerEvent(E,"htmx:abort");else{if(w==null){if(r){let m=getInternalData(r);m&&m.triggerSpec&&m.triggerSpec.queue&&(w=m.triggerSpec.queue)}w==null&&(w="last")}return d.queuedRequests==null&&(d.queuedRequests=[]),w==="first"&&d.queuedRequests.length===0?d.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="all"?d.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="last"&&(d.queuedRequests=[],d.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(s),a}let x=new XMLHttpRequest;d.xhr=x,d.abortable=C;let p=function(){d.xhr=null,d.abortable=!1,d.queuedRequests!=null&&d.queuedRequests.length>0&&d.queuedRequests.shift()()},k=getClosestAttributeValue(n,"hx-prompt");if(k){var P=prompt(k);if(P===null||!triggerEvent(n,"htmx:prompt",{prompt:P,target:u}))return maybeCall(s),p(),a}if(h&&!i&&!confirm(h))return maybeCall(s),p(),a;let H=getHeaders(n,u,P);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let B=getInputValues(n,e),D=B.errors,U=B.formData;o.values&&overrideFormData(U,formDataFromObject(o.values));let J=formDataFromObject(getExpressionVars(n,r)),F=overrideFormData(U,J),R=filterValues(F,n);htmx.config.getCacheBusterParam&&e==="get"&&R.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let N=getValuesForElement(n,"hx-request"),_=getInternalData(n).boosted,I=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,A={boosted:_,useUrlParams:I,formData:R,parameters:formDataProxy(R),unfilteredFormData:F,unfilteredParameters:formDataProxy(F),headers:H,elt:n,target:u,verb:e,errors:D,withCredentials:o.credentials||N.credentials||htmx.config.withCredentials,timeout:o.timeout||N.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",A))return maybeCall(s),p(),a;if(t=A.path,e=A.verb,H=A.headers,R=formDataFromObject(A.parameters),D=A.errors,I=A.useUrlParams,D&&D.length>0)return triggerEvent(n,"htmx:validation:halted",A),maybeCall(s),p(),a;let W=t.split("#"),Y=W[0],M=W[1],S=t;if(I&&(S=Y,!R.keys().next().done&&(S.indexOf("?")<0?S+="?":S+="&",S+=urlEncode(R),M&&(S+="#"+M))),!verifyPath(n,S,A))return triggerErrorEvent(n,"htmx:invalidPath",A),maybeCall(l),p(),a;if(x.open(e.toUpperCase(),S,!0),x.overrideMimeType("text/html"),x.withCredentials=A.withCredentials,x.timeout=A.timeout,!N.noHeaders){for(let m in H)if(H.hasOwnProperty(m)){let b=H[m];safelySetHeaderValue(x,m,b)}}let v={xhr:x,target:u,requestConfig:A,etc:o,boosted:_,select:f,pathInfo:{requestPath:t,finalRequestPath:S,responsePath:null,anchor:M}};if(x.onload=function(){try{let m=hierarchyForElt(n);if(v.pathInfo.responsePath=getPathFromResponse(x),c(n,v),v.keepIndicators!==!0&&removeRequestIndicators(O,L),triggerEvent(n,"htmx:afterRequest",v),triggerEvent(n,"htmx:afterOnLoad",v),!bodyContains(n)){let b=null;for(;m.length>0&&b==null;){let T=m.shift();bodyContains(T)&&(b=T)}b&&(triggerEvent(b,"htmx:afterRequest",v),triggerEvent(b,"htmx:afterOnLoad",v))}maybeCall(s)}catch(m){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:m},v)),m}finally{p()}},x.onerror=function(){removeRequestIndicators(O,L),triggerErrorEvent(n,"htmx:afterRequest",v),triggerErrorEvent(n,"htmx:sendError",v),maybeCall(l),p()},x.onabort=function(){removeRequestIndicators(O,L),triggerErrorEvent(n,"htmx:afterRequest",v),triggerErrorEvent(n,"htmx:sendAbort",v),maybeCall(l),p()},x.ontimeout=function(){removeRequestIndicators(O,L),triggerErrorEvent(n,"htmx:afterRequest",v),triggerErrorEvent(n,"htmx:timeout",v),maybeCall(l),p()},!triggerEvent(n,"htmx:beforeRequest",v))return maybeCall(s),p(),a;var O=addRequestIndicatorClasses(n),L=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(m){forEach([x,x.upload],function(b){b.addEventListener(m,function(T){triggerEvent(n,"htmx:xhr:"+m,{lengthComputable:T.lengthComputable,loaded:T.loaded,total:T.total})})})}),triggerEvent(n,"htmx:beforeSend",v);let G=I?null:encodeParamsForBody(x,n,R);return x.send(G),a}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let i=t.pathInfo.finalRequestPath,s=t.pathInfo.responsePath,l=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),a=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,f=null,u=null;return l?(f="push",u=l):a?(f="replace",u=a):c&&(f="push",u=s||i),u?u==="false"?{}:(u==="true"&&(u=s||i),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:f,path:u}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t<htmx.config.responseHandling.length;t++){var n=htmx.config.responseHandling[t];if(codeMatches(n,e.status))return n}return{swap:!1}}function handleTitle(e){if(e){let t=find("title");t?t.textContent=e:window.document.title=e}}function resolveRetarget(e,t){if(t==="this")return e;let n=asElement(querySelectorExt(e,t));if(n==null)throw triggerErrorEvent(e,"htmx:targetError",{target:t}),new Error(`Invalid re-target ${t}`);return n}function handleAjaxResponse(e,t){let n=t.xhr,r=t.target,o=t.etc,i=t.select;if(!triggerEvent(e,"htmx:beforeOnLoad",t))return;if(hasHeader(n,/HX-Trigger:/i)&&handleTriggerHeader(n,"HX-Trigger",e),hasHeader(n,/HX-Location:/i)){let C=n.getResponseHeader("HX-Location");var s={};C.indexOf("{")===0&&(s=parseJSON(C),C=s.path,delete s.path),s.push=s.push||"true",ajaxHelper("get",C,s);return}let l=hasHeader(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(hasHeader(n,/HX-Redirect:/i)){t.keepIndicators=!0,htmx.location.href=n.getResponseHeader("HX-Redirect"),l&&htmx.location.reload();return}if(l){t.keepIndicators=!0,htmx.location.reload();return}let a=determineHistoryUpdates(e,t),c=resolveResponseHandling(n),f=c.swap,u=!!c.error,d=htmx.config.ignoreTitle||c.ignoreTitle,y=c.select;c.target&&(t.target=resolveRetarget(e,c.target));var h=o.swapOverride;h==null&&c.swapOverride&&(h=c.swapOverride),hasHeader(n,/HX-Retarget:/i)&&(t.target=resolveRetarget(e,n.getResponseHeader("HX-Retarget"))),hasHeader(n,/HX-Reswap:/i)&&(h=n.getResponseHeader("HX-Reswap"));var E=n.response,g=mergeObjects({shouldSwap:f,serverResponse:E,isError:u,ignoreTitle:d,selectOverride:y,swapOverride:h},t);if(!(c.event&&!triggerEvent(r,c.event,g))&&triggerEvent(r,"htmx:beforeSwap",g)){if(r=g.target,E=g.serverResponse,u=g.isError,d=g.ignoreTitle,y=g.selectOverride,h=g.swapOverride,t.target=r,t.failed=u,t.successful=!u,g.shouldSwap){n.status===286&&cancelPolling(e),withExtensions(e,function(p){E=p.transformResponse(E,n,e)}),a.type&&saveCurrentPageToHistory();var w=getSwapSpecification(e,h);w.hasOwnProperty("ignoreTitle")||(w.ignoreTitle=d),r.classList.add(htmx.config.swappingClass),i&&(y=i),hasHeader(n,/HX-Reselect:/i)&&(y=n.getResponseHeader("HX-Reselect"));let C=o.selectOOB||getClosestAttributeValue(e,"hx-select-oob"),x=getClosestAttributeValue(e,"hx-select");swap(r,E,w,{select:y==="unset"?null:y||x,selectOOB:C,eventInfo:t,anchor:t.pathInfo.anchor,contextElement:e,afterSwapCallback:function(){if(hasHeader(n,/HX-Trigger-After-Swap:/i)){let p=e;bodyContains(e)||(p=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Swap",p)}},afterSettleCallback:function(){if(hasHeader(n,/HX-Trigger-After-Settle:/i)){let p=e;bodyContains(e)||(p=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Settle",p)}},beforeSwapCallback:function(){a.type&&(triggerEvent(getDocument().body,"htmx:beforeHistoryUpdate",mergeObjects({history:a},t)),a.type==="push"?(pushUrlIntoHistory(a.path),triggerEvent(getDocument().body,"htmx:pushedIntoHistory",{path:a.path})):(replaceUrlInHistory(a.path),triggerEvent(getDocument().body,"htmx:replacedInHistory",{path:a.path})))}})}u&&triggerErrorEvent(e,"htmx:responseError",mergeObjects({error:"Response Status Error Code "+n.status+" from "+t.pathInfo.requestPath},t))}}let extensions={};function extensionBase(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,n,r){return!1},encodeParameters:function(e,t,n){return null}}}function defineExtension(e,t){t.init&&t.init(internalAPI),extensions[e]=mergeObjects(extensionBase(),t)}function removeExtension(e){delete extensions[e]}function getExtensions(e,t,n){if(t==null&&(t=[]),e==null)return t;n==null&&(n=[]);let r=getAttributeValue(e,"hx-ext");return r&&forEach(r.split(","),function(o){if(o=o.replace(/ /g,""),o.slice(0,7)=="ignore:"){n.push(o.slice(7));return}if(n.indexOf(o)<0){let i=extensions[o];i&&t.indexOf(i)<0&&t.push(i)}}),getExtensions(asElement(parentElt(e)),t,n)}var isReady=!1;getDocument().addEventListener("DOMContentLoaded",function(){isReady=!0});function ready(e){isReady||getDocument().readyState==="complete"?e():getDocument().addEventListener("DOMContentLoaded",e)}function insertIndicatorStyles(){if(htmx.config.includeIndicatorStyles!==!1){let e=htmx.config.inlineStyleNonce?` nonce="${htmx.config.inlineStyleNonce}"`:"",t=htmx.config.indicatorClass,n=htmx.config.requestClass;getDocument().head.insertAdjacentHTML("beforeend",`<style${e}>.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}</style>`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,i=getInternalData(o);i&&i.xhr&&i.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),j=Q;window.htmx=j;function K(e){try{return localStorage.getItem(e)}catch{return null}}function Z(e,t){try{localStorage.setItem(e,t)}catch{}}function X(){return K("hold-admin-theme")||"system"}function ee(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function V(){let e=X(),n=ee(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),te(e)}function z(e){Z("hold-admin-theme",e),V(),ne()}function te(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/admin/public/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e,o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden"),n.setAttribute("aria-checked",r?"true":"false")})}function ne(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{X()==="system"&&V()});function re(){let e=document.getElementById("did"),t=document.getElementById("lookup-btn"),n=document.getElementById("handle-result");if(!e||!t||!n)return;function r(i,s,l){if(!document.contains(n))return;n.textContent="";let a=document.createElement("span");if(a.className=i,l){let c=document.createElementNS("http://www.w3.org/2000/svg","svg");c.setAttribute("class","icon size-4"),c.setAttribute("aria-hidden","true");let f=document.createElementNS("http://www.w3.org/2000/svg","use");f.setAttribute("href",l),c.appendChild(f),a.appendChild(c),a.appendChild(document.createTextNode(" "));let u=document.createElement("strong");u.textContent=s,a.appendChild(u)}else a.textContent=s;n.appendChild(a)}async function o(){let i=e.value.trim();if(!i.startsWith("did:")){r("text-error","Invalid DID format");return}r("text-base-content/50 italic","Looking up...");try{let s;if(i.startsWith("did:plc:"))s=`https://plc.directory/${i}`;else if(i.startsWith("did:web:"))s=`https://${i.replace("did:web:","").replace(/%3A/g,":")}/.well-known/did.json`;else{if(!document.contains(n))return;r("text-error","Unsupported DID method");return}let l=await fetch(s);if(!l.ok)throw new Error("DID not found");let a=await l.json();if(!document.contains(n))return;let f=(a.alsoKnownAs||[]).find(u=>u.startsWith("at://"));if(f){let u=f.replace("at://","");r("text-success flex items-center gap-1",u,"/admin/public/icons.svg#check-circle")}else r("text-warning","No handle found")}catch(s){if(!document.contains(n))return;r("text-error",`Lookup failed: ${s.message}`)}}t.addEventListener("click",o),e.addEventListener("blur",function(){this.value.startsWith("did:")&&this.value.length>10&&o()})}function $(){let e=document.getElementById("toast-container");return e||(e=document.createElement("div"),e.id="toast-container",e.className="toast toast-end toast-bottom z-50",e.setAttribute("aria-live","polite"),e.setAttribute("aria-atomic","false"),document.body&&document.body.appendChild(e),e)}function q(e,t){let n=$(),r=t==="error",o=r?"alert-error":t==="warning"?"alert-warning":"alert-success",i=document.createElement("div");i.className=`alert ${o} shadow-lg transition-opacity duration-300`,i.setAttribute("role",r?"alert":"status");let s=document.createElement("span");s.textContent=e,i.appendChild(s),n.appendChild(i),setTimeout(()=>{i.style.opacity="0",setTimeout(()=>i.remove(),300)},3e3)}document.body.addEventListener("htmx:responseError",e=>{let t=e.detail&&e.detail.elt;if(t&&t.closest&&t.closest("[data-suppress-htmx-toast]"))return;let n=e.detail&&e.detail.xhr,r=n&&n.getResponseHeader&&n.getResponseHeader("HX-Trigger");if(r&&r.indexOf("toast")!==-1)return;let o=n?n.status:0,i=o===401?"Session expired \u2014 please sign in again":o===403?"Not authorized":o===404?"Not found":o===429?"Too many requests \u2014 please slow down":o>=500?"Server error \u2014 please try again":"Something went wrong";q(i,"error")});document.body.addEventListener("htmx:sendError",e=>{let t=e.detail&&e.detail.elt;t&&t.closest&&t.closest("[data-suppress-htmx-toast]")||q("Network error \u2014 check your connection","error")});document.body.addEventListener("toast",e=>{let t=e&&e.detail||{},n=t.message||t.msg||"";n&&q(n,t.type||"info")});document.addEventListener("DOMContentLoaded",()=>{$(),V(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{z(t.dataset.value)})})}),document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");if(!t)return;let n=()=>e.setAttribute("aria-expanded",t.open?"true":"false");n(),t.addEventListener("toggle",n)}),re()});window.setTheme=z;window.showToast=q;
+96 -45
pkg/hold/admin/src/css/main.css
··· 94 94 /* ======================================== 95 95 ADDITIONAL CSS VARIABLES 96 96 ======================================== */ 97 + /* Card elevation. In dark mode, shadows disappear against a dark surface — 98 + depth is communicated primarily by the base-100 → base-200 surface ramp. 99 + Tinted or glow shadows read as highlights, not elevation; use neutral-black. */ 97 100 :root { 98 - --shadow-card-hover: 99 - 0 8px 25px oklch(67.1% 0.05 145 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1); 101 + --color-helm-light: oklch(31% 0.181 267.5); 102 + --color-helm-dark: oklch(64.6% 0.19 273.2); 100 103 } 101 104 102 105 [data-theme="dark"] { 103 106 --shadow-card-hover: 104 - 0 8px 25px oklch(67.1% 0.05 145 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2); 107 + 0 2px 4px -1px oklch(0% 0 0 / 0.55), 108 + 0 12px 24px -6px oklch(0% 0 0 / 0.45); 105 109 } 106 110 107 111 [data-theme="light"] { 108 112 --shadow-card-hover: 109 - 0 8px 25px oklch(53.1% 0.1 144.8 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1); 110 - } 111 - 112 - [data-theme="dark"] { 113 - --shadow-card-hover: 114 - 0 8px 25px oklch(63.1% 0.07 144.7 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2); 113 + 0 1px 2px oklch(0% 0 0 / 0.06), 114 + 0 8px 24px -6px oklch(0% 0 0 / 0.12); 115 115 } 116 116 117 117 /* ======================================== ··· 260 260 261 261 /* ---------------------------------------- 262 262 HELM BRAND COLOR (official Helm blue #0F1689) 263 + Tokens live on :root (--color-helm-{light,dark}) so the value is 264 + declared once and any future brand shift updates every consumer. 263 265 ---------------------------------------- */ 264 266 .text-helm { 265 - @apply text-[oklch(31%_0.181_267.5)]; 267 + color: var(--color-helm-light); 266 268 } 267 269 268 270 [data-theme="dark"] .text-helm { 269 - @apply text-[oklch(64.6%_0.19_273.2)]; 271 + color: var(--color-helm-dark); 270 272 } 271 273 272 274 .badge-helm { 273 - --badge-color: oklch(31% 0.181 267.5); 275 + --badge-color: var(--color-helm-light); 274 276 } 275 277 276 278 [data-theme="dark"] .badge-helm { 277 - --badge-color: oklch(64.6% 0.19 273.2); 279 + --badge-color: var(--color-helm-dark); 278 280 } 279 281 280 282 /* ---------------------------------------- ··· 296 298 CARD EXTENSIONS 297 299 ---------------------------------------- */ 298 300 .card-interactive { 299 - @apply cursor-pointer duration-500; 300 - transition-property: box-shadow, transform; 301 - } 302 - 303 - .card-interactive:hover { 304 - box-shadow: var(--shadow-card-hover); 305 - transform: translateY(-2px); 306 - } 307 - 308 - /* ---------------------------------------- 309 - ACTOR-TYPEAHEAD COMPONENT STYLING 310 - ---------------------------------------- */ 311 - actor-typeahead { 312 - /* Use DaisyUI CSS variables - they auto-switch with theme */ 313 - --color-background: var(--color-base-100); 314 - --color-border: var(--color-base-300); 315 - --color-shadow: var(--color-base-content); 316 - --color-hover: var(--color-base-200); 317 - --color-avatar-fallback: var(--color-base-300); 318 - --radius: 0.5rem; 319 - --padding-menu: 0.25rem; 320 - z-index: 50; 321 - } 322 - 323 - actor-typeahead::part(handle) { 324 - @apply text-base-content; 325 - } 326 - 327 - actor-typeahead::part(menu) { 328 - @apply shadow-lg; 329 - margin-top: 0.25rem; 301 + @apply cursor-pointer; 302 + transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1), 303 + box-shadow 250ms cubic-bezier(0.16, 1, 0.3, 1); 330 304 } 331 305 332 306 /* ---------------------------------------- ··· 351 325 @apply text-base-content; 352 326 } 353 327 354 - .recent-accounts-item:hover, 355 328 .recent-accounts-item.focused { 356 329 @apply bg-base-200; 357 330 } ··· 369 342 @apply block w-full; 370 343 } 371 344 } 345 + 346 + /* ======================================== 347 + KEYBOARD FOCUS RING 348 + Applied only on :focus-visible so mouse users don't see it. 349 + Uses the primary hue so it reads as part of the Deep Ocean palette. 350 + ======================================== */ 351 + :where(a, button, [role="button"], [role="tab"], input, select, textarea, summary, [tabindex]):focus-visible { 352 + outline: 2px solid var(--color-primary); 353 + outline-offset: 2px; 354 + } 355 + 356 + /* ======================================== 357 + HOVER-ONLY CARD AFFORDANCE 358 + Guard behind pointer: fine to prevent sticky hover state on touch. 359 + ======================================== */ 360 + @media (hover: hover) and (pointer: fine) { 361 + .card-interactive:hover { 362 + box-shadow: var(--shadow-card-hover); 363 + transform: translateY(-2px); 364 + } 365 + 366 + .recent-accounts-item:hover { 367 + @apply bg-base-200; 368 + } 369 + } 370 + 371 + /* ======================================== 372 + TOUCH TARGET SIZING 373 + Small buttons and compact form controls meet the keyboard minimum on 374 + desktop but fall below the 44×44 recommended touch target on touch 375 + devices (WCAG 2.5.5). Grow them on any device that can't reliably 376 + produce hover — covers pure touch as well as hybrid touchscreen 377 + laptops where `pointer: coarse` alone misses. 378 + ======================================== */ 379 + @media (pointer: coarse), (hover: none) { 380 + /* Icon-only buttons grow both axes — daisyUI's circle/square variants 381 + are the marker for these. */ 382 + :is(.btn-circle, .btn-square):is(.btn-xs, .btn-sm) { 383 + min-width: 2.75rem; 384 + min-height: 2.75rem; 385 + } 386 + 387 + /* Text buttons only need vertical clearance — padding handles width. */ 388 + .btn-xs, .btn-sm { 389 + min-height: 2.75rem; 390 + } 391 + 392 + /* Small checkbox/radio: expand the clickable region without distorting 393 + the control itself. */ 394 + :is(.checkbox, .radio):is(.checkbox-xs, .radio-xs, .checkbox-sm, .radio-sm) { 395 + min-width: 1.5rem; 396 + min-height: 1.5rem; 397 + } 398 + 399 + /* daisyUI menu items (used in dropdowns) are small link-like elements. 400 + Ensure they meet the tap threshold. */ 401 + .menu li > a, 402 + .menu li > button { 403 + min-height: 2.75rem; 404 + } 405 + } 406 + 407 + /* ======================================== 408 + REDUCED MOTION 409 + Honor users who opt out of animation. Collapse all durations to a 410 + near-instant value rather than removing transitions entirely, so 411 + state changes still fire (transitionend listeners, etc.). 412 + ======================================== */ 413 + @media (prefers-reduced-motion: reduce) { 414 + *, 415 + *::before, 416 + *::after { 417 + animation-duration: 0.01ms !important; 418 + animation-iteration-count: 1 !important; 419 + transition-duration: 0.01ms !important; 420 + scroll-behavior: auto !important; 421 + } 422 + }
+134 -8
pkg/hold/admin/src/js/main.js
··· 2 2 import htmx from 'htmx.org'; 3 3 window.htmx = htmx; 4 4 5 + // Safe localStorage wrappers. Safari private mode, disabled storage, and 6 + // quota-exceeded all throw from getItem/setItem — absorb those failures so 7 + // individual features degrade silently instead of crashing the page. 8 + function lsGet(key) { 9 + try { return localStorage.getItem(key); } catch (_) { return null; } 10 + } 11 + function lsSet(key, value) { 12 + try { localStorage.setItem(key, value); } catch (_) { /* quota/disabled */ } 13 + } 14 + 5 15 // ======================================== 6 16 // Theme management (system / light / dark) 7 17 // ======================================== 8 18 function getThemePreference() { 9 - return localStorage.getItem('hold-admin-theme') || 'system'; 19 + return lsGet('hold-admin-theme') || 'system'; 10 20 } 11 21 12 22 function getEffectiveTheme(pref) { ··· 26 36 } 27 37 28 38 function setTheme(theme) { 29 - localStorage.setItem('hold-admin-theme', theme); 39 + lsSet('hold-admin-theme', theme); 30 40 applyTheme(); 31 41 closeThemeDropdown(); 32 42 } ··· 43 53 if (check) { 44 54 check.style.visibility = isSelected ? 'visible' : 'hidden'; 45 55 } 56 + option.setAttribute('aria-checked', isSelected ? 'true' : 'false'); 46 57 }); 47 58 } 48 59 ··· 70 81 71 82 if (!didInput || !lookupBtn || !handleResult) return; 72 83 84 + // Build a simple text node inside a wrapper span — used to avoid innerHTML 85 + // with externally-controlled strings. 86 + function setHandleResult(className, text, iconHref) { 87 + if (!document.contains(handleResult)) return; 88 + handleResult.textContent = ''; 89 + const span = document.createElement('span'); 90 + span.className = className; 91 + if (iconHref) { 92 + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 93 + svg.setAttribute('class', 'icon size-4'); 94 + svg.setAttribute('aria-hidden', 'true'); 95 + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); 96 + use.setAttribute('href', iconHref); 97 + svg.appendChild(use); 98 + span.appendChild(svg); 99 + span.appendChild(document.createTextNode(' ')); 100 + const strong = document.createElement('strong'); 101 + strong.textContent = text; 102 + span.appendChild(strong); 103 + } else { 104 + span.textContent = text; 105 + } 106 + handleResult.appendChild(span); 107 + } 108 + 73 109 async function lookupHandle() { 74 110 const did = didInput.value.trim(); 75 111 if (!did.startsWith('did:')) { 76 - handleResult.innerHTML = '<span class="text-error">Invalid DID format</span>'; 112 + setHandleResult('text-error', 'Invalid DID format'); 77 113 return; 78 114 } 79 115 80 - handleResult.innerHTML = '<span class="text-base-content/50 italic">Looking up...</span>'; 116 + setHandleResult('text-base-content/50 italic', 'Looking up...'); 81 117 82 118 try { 83 119 let url; ··· 87 123 const host = did.replace('did:web:', '').replace(/%3A/g, ':'); 88 124 url = `https://${host}/.well-known/did.json`; 89 125 } else { 90 - handleResult.innerHTML = '<span class="text-error">Unsupported DID method</span>'; 126 + if (!document.contains(handleResult)) return; 127 + setHandleResult('text-error', 'Unsupported DID method'); 91 128 return; 92 129 } 93 130 ··· 95 132 if (!resp.ok) throw new Error('DID not found'); 96 133 97 134 const doc = await resp.json(); 135 + if (!document.contains(handleResult)) return; 98 136 const aka = doc.alsoKnownAs || []; 99 137 const handleUri = aka.find(u => u.startsWith('at://')); 100 138 if (handleUri) { 101 139 const handle = handleUri.replace('at://', ''); 102 - handleResult.innerHTML = `<span class="text-success flex items-center gap-1"><svg class="icon size-4" aria-hidden="true"><use href="/admin/public/icons.svg#check-circle"></use></svg> <strong>${handle}</strong></span>`; 140 + setHandleResult( 141 + 'text-success flex items-center gap-1', 142 + handle, 143 + '/admin/public/icons.svg#check-circle' 144 + ); 103 145 } else { 104 - handleResult.innerHTML = '<span class="text-warning">No handle found</span>'; 146 + setHandleResult('text-warning', 'No handle found'); 105 147 } 106 148 } catch (err) { 107 - handleResult.innerHTML = `<span class="text-error">Lookup failed: ${err.message}</span>`; 149 + if (!document.contains(handleResult)) return; 150 + setHandleResult('text-error', `Lookup failed: ${err.message}`); 108 151 } 109 152 } 110 153 ··· 118 161 } 119 162 120 163 // ======================================== 164 + // Toast notifications + htmx error handling 165 + // ======================================== 166 + // Pre-create the toast container on DOMContentLoaded so the aria-live region 167 + // exists before any announcement. If the very first toast fires earlier 168 + // (e.g. an htmx:responseError during initial boot) ensureToastContainer() 169 + // constructs it lazily. 170 + function ensureToastContainer() { 171 + let container = document.getElementById('toast-container'); 172 + if (container) return container; 173 + container = document.createElement('div'); 174 + container.id = 'toast-container'; 175 + container.className = 'toast toast-end toast-bottom z-50'; 176 + container.setAttribute('aria-live', 'polite'); 177 + container.setAttribute('aria-atomic', 'false'); 178 + if (document.body) document.body.appendChild(container); 179 + return container; 180 + } 181 + 182 + function showToast(message, type) { 183 + const container = ensureToastContainer(); 184 + const isError = type === 'error'; 185 + const alertClass = isError ? 'alert-error' : (type === 'warning' ? 'alert-warning' : 'alert-success'); 186 + const toast = document.createElement('div'); 187 + toast.className = `alert ${alertClass} shadow-lg transition-opacity duration-300`; 188 + toast.setAttribute('role', isError ? 'alert' : 'status'); 189 + const span = document.createElement('span'); 190 + span.textContent = message; 191 + toast.appendChild(span); 192 + container.appendChild(toast); 193 + setTimeout(() => { 194 + toast.style.opacity = '0'; 195 + setTimeout(() => toast.remove(), 300); 196 + }, 3000); 197 + } 198 + 199 + // Global htmx error handlers. Opt-out: any ancestor with 200 + // data-suppress-htmx-toast skips the toast (for components that render their 201 + // own inline error state). If the server already triggered a toast via 202 + // HX-Trigger, skip to avoid double-firing. 203 + document.body.addEventListener('htmx:responseError', (evt) => { 204 + const elt = evt.detail && evt.detail.elt; 205 + if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return; 206 + const xhr = evt.detail && evt.detail.xhr; 207 + const trigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger'); 208 + if (trigger && trigger.indexOf('toast') !== -1) return; 209 + const status = xhr ? xhr.status : 0; 210 + const msg = status === 401 ? 'Session expired — please sign in again' 211 + : status === 403 ? 'Not authorized' 212 + : status === 404 ? 'Not found' 213 + : status === 429 ? 'Too many requests — please slow down' 214 + : status >= 500 ? 'Server error — please try again' 215 + : 'Something went wrong'; 216 + showToast(msg, 'error'); 217 + }); 218 + 219 + document.body.addEventListener('htmx:sendError', (evt) => { 220 + const elt = evt.detail && evt.detail.elt; 221 + if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return; 222 + showToast('Network error — check your connection', 'error'); 223 + }); 224 + 225 + // Server-triggered toast via HX-Trigger JSON header. 226 + // Accepts { "toast": { "message": "...", "type": "success|error|warning" } }. 227 + document.body.addEventListener('toast', (evt) => { 228 + const d = (evt && evt.detail) || {}; 229 + const message = d.message || d.msg || ''; 230 + if (!message) return; 231 + showToast(message, d.type || 'info'); 232 + }); 233 + 234 + // ======================================== 121 235 // Init 122 236 // ======================================== 123 237 document.addEventListener('DOMContentLoaded', () => { 238 + ensureToastContainer(); 124 239 applyTheme(); 125 240 126 241 // Theme dropdown setup ··· 132 247 }); 133 248 }); 134 249 250 + // Sync aria-expanded on theme-toggle <summary> with the native <details> 251 + // open state so SR announcements match actual disclosure state. 252 + document.querySelectorAll('[data-theme-toggle]').forEach(btn => { 253 + const details = btn.closest('details'); 254 + if (!details) return; 255 + const sync = () => btn.setAttribute('aria-expanded', details.open ? 'true' : 'false'); 256 + sync(); 257 + details.addEventListener('toggle', sync); 258 + }); 259 + 135 260 // DID lookup on crew add page 136 261 initDIDLookup(); 137 262 }); 138 263 139 264 // Export for template onclick handlers 140 265 window.setTheme = setTheme; 266 + window.showToast = showToast;
+9 -2
pkg/hold/admin/templates/components/head.html
··· 1 1 {{define "admin-head"}} 2 2 <meta charset="UTF-8"> 3 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4 + <meta name="color-scheme" content="dark light"> 5 + <meta name="referrer" content="strict-origin-when-cross-origin"> 6 + <meta name="robots" content="noindex, nofollow"> 4 7 5 - <!-- Theme: apply early to prevent flash --> 8 + <!-- Theme: apply early to prevent flash. 9 + Wrapped in try/catch — Safari Private and quota-exceeded both throw 10 + synchronously from localStorage.getItem, and an unhandled throw here 11 + would block HTML parsing and leave the page on the browser default. --> 6 12 <script> 7 13 (function() { 8 14 function getEffectiveTheme(pref) { ··· 10 16 if (pref === 'light') return 'light'; 11 17 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 12 18 } 13 - var pref = localStorage.getItem('hold-admin-theme') || 'system'; 19 + var pref = 'system'; 20 + try { pref = localStorage.getItem('hold-admin-theme') || 'system'; } catch (_) {} 14 21 var effective = getEffectiveTheme(pref); 15 22 document.documentElement.classList.toggle('dark', effective === 'dark'); 16 23 document.documentElement.setAttribute('data-theme', effective);
+7 -22
pkg/hold/admin/templates/components/layout.html
··· 5 5 {{template "admin-head"}} 6 6 <title>{{.Title}} - Hold Admin</title> 7 7 </head> 8 - <body class="min-h-screen flex flex-col bg-base-200"> 8 + <body class="min-h-screen flex flex-col bg-base-200" hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'> 9 + <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-primary focus:text-primary-content focus:px-3 focus:py-2 focus:rounded">Skip to main content</a> 10 + 9 11 {{template "nav" .}} 10 12 11 13 <div class="flex-1 flex"> 12 14 {{template "admin-sidebar" .}} 13 15 14 - <main class="flex-1 min-w-0"> 16 + <main id="main-content" tabindex="-1" class="flex-1 min-w-0"> 15 17 <div class="max-w-480 mx-auto px-6 pt-6 pb-6"> 16 18 {{if .Flash}} 17 - <div role="alert" class="alert alert-{{.Flash.Category}} mb-4"> 19 + <div role="{{if eq .Flash.Category "error"}}alert{{else}}status{{end}}" 20 + aria-live="{{if eq .Flash.Category "error"}}assertive{{else}}polite{{end}}" 21 + class="alert alert-{{.Flash.Category}} mb-4"> 18 22 <span>{{.Flash.Message}}</span> 19 23 </div> 20 24 {{end}} ··· 28 32 </main> 29 33 </div> 30 34 31 - <footer class="text-center p-6 text-base-content/50 text-sm"> 32 - <p>Hold: <code class="font-mono">{{.HoldDID}}</code></p> 33 - </footer> 34 - 35 - {{if .ActivePage}} 36 - <script> 37 - (function() { 38 - var active = "{{.ActivePage}}"; 39 - var li = document.querySelector('.menu li[data-tab="' + active + '"]'); 40 - if (li) li.classList.add('menu-active'); 41 - document.querySelectorAll('.admin-tab-mobile').forEach(function(a) { 42 - if (a.dataset.tab === active) { 43 - a.classList.remove('btn-ghost'); 44 - a.classList.add('btn-secondary'); 45 - } 46 - }); 47 - })(); 48 - </script> 49 - {{end}} 50 35 </body> 51 36 </html> 52 37 {{end}}
+5 -4
pkg/hold/admin/templates/components/nav.html
··· 1 1 {{define "nav"}} 2 - <div class="navbar bg-neutral text-neutral-content px-4 shadow-md"> 2 + <nav class="navbar bg-neutral text-neutral-content px-4 shadow-md" aria-label="Primary navigation"> 3 3 <div class="flex-none"> 4 4 <a href="/admin" class="text-lg font-semibold hover:opacity-80 transition-opacity">Hold Admin</a> 5 5 </div> ··· 7 7 {{if .User}} 8 8 <div class="flex items-center gap-3"> 9 9 {{template "admin-theme-toggle"}} 10 - <span class="text-sm opacity-80">{{.User.Handle}}</span> 10 + <span class="text-sm opacity-80 truncate max-w-[16ch]" title="{{.User.Handle}}">{{.User.Handle}}</span> 11 11 <form method="POST" action="/admin/auth/logout" class="inline"> 12 - <button type="submit" class="btn btn-sm btn-ghost">Logout</button> 12 + {{ csrfInput .CSRFToken }} 13 + <button type="submit" class="btn btn-sm btn-ghost" aria-label="Sign out of Hold Admin">Logout</button> 13 14 </form> 14 15 </div> 15 16 {{end}} 16 - </div> 17 + </nav> 17 18 {{end}}
+48 -13
pkg/hold/admin/templates/components/sidebar.html
··· 1 1 {{define "admin-sidebar-mobile"}} 2 2 <!-- Mobile tab bar (below lg) --> 3 - <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6"> 4 - <a href="/admin#dashboard" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="dashboard"> 3 + <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6" role="tablist" aria-label="Admin sections"> 4 + <a id="tab-label-dashboard-mobile" href="/admin#dashboard" 5 + class="btn btn-sm btn-ghost admin-tab-mobile" 6 + role="tab" 7 + data-tab="dashboard" 8 + aria-controls="tab-dashboard" 9 + aria-selected="false" 10 + tabindex="-1"> 5 11 {{ icon "compass" "size-4" }} Dashboard 6 12 </a> 7 - <a href="/admin#crew" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="crew"> 13 + <a id="tab-label-crew-mobile" href="/admin#crew" 14 + class="btn btn-sm btn-ghost admin-tab-mobile" 15 + role="tab" 16 + data-tab="crew" 17 + aria-controls="tab-crew" 18 + aria-selected="false" 19 + tabindex="-1"> 8 20 {{ icon "anchor" "size-4" }} Crew 9 21 </a> 10 - <a href="/admin#settings" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="settings"> 22 + <a id="tab-label-settings-mobile" href="/admin#settings" 23 + class="btn btn-sm btn-ghost admin-tab-mobile" 24 + role="tab" 25 + data-tab="settings" 26 + aria-controls="tab-settings" 27 + aria-selected="false" 28 + tabindex="-1"> 11 29 {{ icon "settings" "size-4" }} Settings 12 30 </a> 13 - <a href="/admin#relays" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="relays"> 31 + <a id="tab-label-relays-mobile" href="/admin#relays" 32 + class="btn btn-sm btn-ghost admin-tab-mobile" 33 + role="tab" 34 + data-tab="relays" 35 + aria-controls="tab-relays" 36 + aria-selected="false" 37 + tabindex="-1"> 14 38 {{ icon "radio-tower" "size-4" }} Relays 15 39 </a> 16 - <a href="/admin#storage" class="btn btn-sm btn-ghost admin-tab-mobile" data-tab="storage"> 40 + <a id="tab-label-storage-mobile" href="/admin#storage" 41 + class="btn btn-sm btn-ghost admin-tab-mobile" 42 + role="tab" 43 + data-tab="storage" 44 + aria-controls="tab-storage" 45 + aria-selected="false" 46 + tabindex="-1"> 17 47 {{ icon "hard-drive" "size-4" }} Storage 18 48 </a> 19 49 </div> ··· 21 51 22 52 {{define "admin-sidebar"}} 23 53 <!-- Sidebar (lg and above) — pinned to left edge --> 24 - <aside class="hidden lg:block w-64 shrink-0 sticky top-0 h-screen overflow-y-auto bg-base-200 pt-6 px-4"> 25 - <ul class="menu menu-lg rounded-box w-full"> 26 - <li data-tab="dashboard"><a href="/admin#dashboard">{{ icon "compass" "size-5" }} Dashboard</a></li> 27 - <li data-tab="crew"><a href="/admin#crew">{{ icon "anchor" "size-5" }} Crew</a></li> 28 - <li data-tab="settings"><a href="/admin#settings">{{ icon "settings" "size-5" }} Settings</a></li> 29 - <li data-tab="relays"><a href="/admin#relays">{{ icon "radio-tower" "size-5" }} Relays</a></li> 30 - <li data-tab="storage"><a href="/admin#storage">{{ icon "hard-drive" "size-5" }} Storage</a></li> 54 + <aside class="hidden lg:block w-64 shrink-0 sticky top-0 h-screen overflow-y-auto bg-base-200 pt-6 px-4" 55 + aria-label="Admin navigation"> 56 + <ul class="menu menu-lg rounded-box w-full" role="tablist" aria-label="Admin sections"> 57 + <li data-tab="dashboard"><a id="tab-label-dashboard" href="/admin#dashboard" role="tab" aria-controls="tab-dashboard" aria-selected="false" tabindex="-1">{{ icon "compass" "size-5" }} Dashboard</a></li> 58 + <li data-tab="crew"><a id="tab-label-crew" href="/admin#crew" role="tab" aria-controls="tab-crew" aria-selected="false" tabindex="-1">{{ icon "anchor" "size-5" }} Crew</a></li> 59 + <li data-tab="settings"><a id="tab-label-settings" href="/admin#settings" role="tab" aria-controls="tab-settings" aria-selected="false" tabindex="-1">{{ icon "settings" "size-5" }} Settings</a></li> 60 + <li data-tab="relays"><a id="tab-label-relays" href="/admin#relays" role="tab" aria-controls="tab-relays" aria-selected="false" tabindex="-1">{{ icon "radio-tower" "size-5" }} Relays</a></li> 61 + <li data-tab="storage"><a id="tab-label-storage" href="/admin#storage" role="tab" aria-controls="tab-storage" aria-selected="false" tabindex="-1">{{ icon "hard-drive" "size-5" }} Storage</a></li> 31 62 </ul> 63 + <div class="mt-4 px-3 text-xs text-base-content/50"> 64 + <p class="uppercase tracking-wide mb-1">Hold</p> 65 + <code class="font-mono break-all block" title="{{.HoldDID}}">{{.HoldDID}}</code> 66 + </div> 32 67 </aside> 33 68 {{end}}
+4 -4
pkg/hold/admin/templates/components/theme-toggle.html
··· 3 3 <summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings"> 4 4 <svg class="icon size-5" data-theme-icon aria-hidden="true"><use href="/admin/public/icons.svg#sun"></use></svg> 5 5 </summary> 6 - <ul data-theme-menu class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg"> 6 + <ul data-theme-menu role="menu" class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg"> 7 7 <li> 8 - <button type="button" class="theme-option" data-value="system"> 8 + <button type="button" role="menuitem" class="theme-option" data-value="system"> 9 9 {{ icon "sun-moon" "size-4" }} 10 10 <span>System</span> 11 11 {{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }} 12 12 </button> 13 13 </li> 14 14 <li> 15 - <button type="button" class="theme-option" data-value="light"> 15 + <button type="button" role="menuitem" class="theme-option" data-value="light"> 16 16 {{ icon "sun" "size-4" }} 17 17 <span>Light</span> 18 18 {{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }} 19 19 </button> 20 20 </li> 21 21 <li> 22 - <button type="button" class="theme-option" data-value="dark"> 22 + <button type="button" role="menuitem" class="theme-option" data-value="dark"> 23 23 {{ icon "moon" "size-4" }} 24 24 <span>Dark</span> 25 25 {{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
+115 -23
pkg/hold/admin/templates/pages/admin.html
··· 3 3 {{define "page-content"}} 4 4 <!-- Dashboard (loads immediately) --> 5 5 <div id="tab-dashboard" class="admin-panel" 6 + role="tabpanel" 7 + aria-labelledby="tab-label-dashboard" 8 + tabindex="0" 6 9 hx-get="/admin/api/tab/dashboard" 7 10 hx-trigger="load" 8 - hx-swap="innerHTML"> 11 + hx-swap="innerHTML" 12 + aria-live="polite" 13 + aria-busy="true"> 9 14 <p class="text-base-content/50 italic">Loading...</p> 10 15 </div> 11 16 12 17 <!-- Crew (loads on first activation) --> 13 18 <div id="tab-crew" class="admin-panel hidden" 19 + role="tabpanel" 20 + aria-labelledby="tab-label-crew" 21 + tabindex="0" 14 22 hx-get="/admin/api/tab/crew" 15 23 hx-trigger="tab:crew from:body once" 16 - hx-swap="innerHTML"> 24 + hx-swap="innerHTML" 25 + aria-live="polite"> 17 26 </div> 18 27 19 28 <!-- Settings (loads on first activation) --> 20 29 <div id="tab-settings" class="admin-panel hidden" 30 + role="tabpanel" 31 + aria-labelledby="tab-label-settings" 32 + tabindex="0" 21 33 hx-get="/admin/api/tab/settings" 22 34 hx-trigger="tab:settings from:body once" 23 - hx-swap="innerHTML"> 35 + hx-swap="innerHTML" 36 + aria-live="polite"> 24 37 </div> 25 38 26 39 <!-- Relays (loads on first activation) --> 27 40 <div id="tab-relays" class="admin-panel hidden" 41 + role="tabpanel" 42 + aria-labelledby="tab-label-relays" 43 + tabindex="0" 28 44 hx-get="/admin/api/tab/relays" 29 45 hx-trigger="tab:relays from:body once" 30 - hx-swap="innerHTML"> 46 + hx-swap="innerHTML" 47 + aria-live="polite"> 31 48 </div> 32 49 33 50 <!-- Storage / GC (loads on first activation) --> 34 51 <div id="tab-storage" class="admin-panel hidden" 52 + role="tabpanel" 53 + aria-labelledby="tab-label-storage" 54 + tabindex="0" 35 55 hx-get="/admin/api/tab/storage" 36 56 hx-trigger="tab:storage from:body once" 37 - hx-swap="innerHTML"> 57 + hx-swap="innerHTML" 58 + aria-live="polite"> 38 59 </div> 39 60 40 61 <script> 41 62 (function() { 42 63 var validTabs = ['dashboard', 'crew', 'settings', 'relays', 'storage']; 64 + var tabTitles = { 65 + dashboard: 'Dashboard', 66 + crew: 'Crew', 67 + settings: 'Settings', 68 + relays: 'Relays', 69 + storage: 'Storage' 70 + }; 43 71 44 72 function switchAdminTab(tabId) { 45 73 if (validTabs.indexOf(tabId) === -1) tabId = 'dashboard'; 46 74 47 75 // Toggle panel visibility 48 76 document.querySelectorAll('.admin-panel').forEach(function(p) { 49 - p.classList.add('hidden'); 77 + var isActive = p.id === 'tab-' + tabId; 78 + p.classList.toggle('hidden', !isActive); 50 79 }); 51 - var panel = document.getElementById('tab-' + tabId); 52 - if (panel) panel.classList.remove('hidden'); 53 80 54 - // Desktop sidebar: toggle menu-active 81 + // Desktop sidebar: toggle menu-active + aria-selected + aria-current 55 82 document.querySelectorAll('.menu li[data-tab]').forEach(function(li) { 56 - if (li.dataset.tab === tabId) { 57 - li.classList.add('menu-active'); 58 - } else { 59 - li.classList.remove('menu-active'); 83 + var isActive = li.dataset.tab === tabId; 84 + li.classList.toggle('menu-active', isActive); 85 + var link = li.querySelector('a, button'); 86 + if (link) { 87 + link.setAttribute('aria-selected', isActive ? 'true' : 'false'); 88 + if (isActive) { 89 + link.setAttribute('aria-current', 'page'); 90 + } else { 91 + link.removeAttribute('aria-current'); 92 + } 93 + link.setAttribute('tabindex', isActive ? '0' : '-1'); 60 94 } 61 95 }); 62 96 63 - // Mobile: toggle btn-secondary / btn-ghost 97 + // Mobile: toggle btn-secondary / btn-ghost + aria-selected 64 98 document.querySelectorAll('.admin-tab-mobile').forEach(function(a) { 65 - if (a.dataset.tab === tabId) { 66 - a.classList.remove('btn-ghost'); 67 - a.classList.add('btn-secondary'); 99 + var isActive = a.dataset.tab === tabId; 100 + a.classList.toggle('btn-secondary', isActive); 101 + a.classList.toggle('btn-ghost', !isActive); 102 + a.setAttribute('aria-selected', isActive ? 'true' : 'false'); 103 + if (isActive) { 104 + a.setAttribute('aria-current', 'page'); 68 105 } else { 69 - a.classList.remove('btn-secondary'); 70 - a.classList.add('btn-ghost'); 106 + a.removeAttribute('aria-current'); 71 107 } 108 + a.setAttribute('tabindex', isActive ? '0' : '-1'); 72 109 }); 73 110 111 + // Update document.title so AT users navigating by page title see the 112 + // active tab (WCAG 2.4.2). The server already renders "{{.Title}} - 113 + // Hold Admin" so we keep that suffix. 114 + document.title = tabTitles[tabId] + ' — Hold Admin'; 115 + 74 116 history.replaceState(null, '', '#' + tabId); 75 117 document.body.dispatchEvent(new CustomEvent('tab:' + tabId)); 76 118 } 77 119 120 + // Arrow-key navigation inside a tablist. Left/Right move focus and 121 + // activate the next tab; Home/End jump to the first/last tab. Only 122 + // enabled when the event target is actually a tab. 123 + function handleTabKey(e, tabs) { 124 + var idx = tabs.indexOf(e.target); 125 + if (idx === -1) return; 126 + var next = -1; 127 + switch (e.key) { 128 + case 'ArrowLeft': 129 + case 'ArrowUp': 130 + next = (idx - 1 + tabs.length) % tabs.length; 131 + break; 132 + case 'ArrowRight': 133 + case 'ArrowDown': 134 + next = (idx + 1) % tabs.length; 135 + break; 136 + case 'Home': 137 + next = 0; 138 + break; 139 + case 'End': 140 + next = tabs.length - 1; 141 + break; 142 + default: 143 + return; 144 + } 145 + e.preventDefault(); 146 + var target = tabs[next]; 147 + switchAdminTab(target.dataset.tab); 148 + target.focus(); 149 + } 150 + 78 151 document.addEventListener('DOMContentLoaded', function() { 79 152 // Mobile tab click handlers 80 - document.querySelectorAll('.admin-tab-mobile').forEach(function(a) { 153 + var mobileTabs = Array.from(document.querySelectorAll('.admin-tab-mobile')); 154 + mobileTabs.forEach(function(a) { 81 155 a.addEventListener('click', function(e) { 82 156 e.preventDefault(); 83 157 switchAdminTab(this.dataset.tab); 84 158 }); 159 + a.addEventListener('keydown', function(e) { handleTabKey(e, mobileTabs); }); 85 160 }); 86 161 87 162 // Desktop sidebar click handlers 88 - document.querySelectorAll('.menu li[data-tab] a').forEach(function(link) { 163 + var desktopTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a')); 164 + desktopTabs.forEach(function(link) { 89 165 link.addEventListener('click', function(e) { 90 166 e.preventDefault(); 91 167 switchAdminTab(this.parentElement.dataset.tab); 92 168 }); 169 + link.addEventListener('keydown', function(e) { handleTabKey(e, desktopTabs); }); 93 170 }); 94 171 95 - // Activate tab from hash (default: dashboard) 96 - requestAnimationFrame(function() { 172 + // Activate tab from hash (default: dashboard). rAF deferral keeps 173 + // the initial state update outside the first paint. 174 + var rafId = requestAnimationFrame(function() { 175 + rafId = 0; 97 176 var hash = window.location.hash.replace('#', '') || 'dashboard'; 98 177 switchAdminTab(hash); 99 178 }); 179 + // Cancel on bfcache/pageshow restore so the callback doesn't run 180 + // against a stale DOM. 181 + window.addEventListener('pagehide', function() { 182 + if (rafId) cancelAnimationFrame(rafId); 183 + }, { once: true }); 100 184 }); 101 185 102 186 // Handle browser back/forward 103 187 window.addEventListener('hashchange', function() { 104 188 var hash = window.location.hash.replace('#', '') || 'dashboard'; 105 189 switchAdminTab(hash); 190 + }); 191 + 192 + // Clear aria-busy when the dashboard panel finishes loading. 193 + document.body.addEventListener('htmx:afterSettle', function(evt) { 194 + var elt = evt.detail && evt.detail.elt; 195 + if (elt && elt.classList && elt.classList.contains('admin-panel')) { 196 + elt.removeAttribute('aria-busy'); 197 + } 106 198 }); 107 199 })(); 108 200 </script>
+7 -2
pkg/hold/admin/templates/pages/crew_add.html
··· 12 12 <div class="card bg-base-100 shadow-sm"> 13 13 <div class="card-body"> 14 14 <form action="/admin/crew/add" method="POST" class="max-w-lg"> 15 + {{ csrfInput .CSRFToken }} 15 16 <fieldset class="fieldset mb-6"> 16 17 <label class="fieldset-label font-medium" for="did">DID</label> 17 18 <div class="join w-full"> 18 19 <input type="text" id="did" name="did" 19 20 class="input input-bordered join-item flex-1" 20 - placeholder="did:plc:..." required> 21 + placeholder="did:plc:..." 22 + autocomplete="off" 23 + inputmode="url" 24 + required> 21 25 <button type="button" id="lookup-btn" class="btn btn-ghost join-item" title="Lookup handle" aria-label="Lookup handle from DID"> 22 26 {{ icon "search" "size-4" }} 23 27 </button> ··· 30 34 <label class="fieldset-label font-medium" for="role">Role</label> 31 35 <input type="text" id="role" name="role" 32 36 class="input input-bordered w-full" 33 - placeholder="member" value="member"> 37 + placeholder="member" value="member" 38 + autocomplete="off"> 34 39 <span class="fieldset-label text-base-content/50">Optional role name (e.g., member, admin)</span> 35 40 </fieldset> 36 41
+1
pkg/hold/admin/templates/pages/crew_edit.html
··· 22 22 23 23 <div class="card-body"> 24 24 <form action="/admin/crew/{{.RKey}}/update" method="POST" class="max-w-lg"> 25 + {{ csrfInput .CSRFToken }} 25 26 <fieldset class="fieldset mb-6"> 26 27 <label class="fieldset-label font-medium" for="role">Role</label> 27 28 <input type="text" id="role" name="role"
+1
pkg/hold/admin/templates/pages/crew_import.html
··· 12 12 <div class="card bg-base-100 shadow-sm"> 13 13 <div class="card-body"> 14 14 <form action="/admin/crew/import" method="POST" enctype="multipart/form-data" class="max-w-lg"> 15 + {{ csrfInput .CSRFToken }} 15 16 <fieldset class="fieldset mb-6"> 16 17 <label class="fieldset-label font-medium" for="crew_file">Crew Export File</label> 17 18 <input type="file" id="crew_file" name="crew_file"
+5 -4
pkg/hold/admin/templates/pages/crew_import_results.html
··· 27 27 <div class="card bg-base-100 shadow-sm"> 28 28 <div class="overflow-x-auto"> 29 29 <table class="table table-zebra"> 30 + <caption class="sr-only">Crew import results</caption> 30 31 <thead> 31 32 <tr> 32 - <th>DID</th> 33 - <th>Status</th> 34 - <th>Detail</th> 33 + <th scope="col">DID</th> 34 + <th scope="col">Status</th> 35 + <th scope="col">Detail</th> 35 36 </tr> 36 37 </thead> 37 38 <tbody> 38 39 {{range .Results}} 39 40 <tr> 40 - <td> 41 + <td scope="row"> 41 42 <div> 42 43 {{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}} 43 44 <code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code>
+5 -1
pkg/hold/admin/templates/pages/error.html
··· 8 8 <body class="min-h-screen flex flex-col bg-base-200"> 9 9 {{template "nav" .}} 10 10 11 - <main class="flex-1 max-w-7xl w-full mx-auto p-6"> 11 + <main id="main-content" tabindex="-1" class="flex-1 max-w-7xl w-full mx-auto p-6"> 12 12 <div class="text-center py-16"> 13 13 <h1 class="text-2xl font-bold mb-4">Error</h1> 14 + {{if .Error}} 14 15 <p class="text-error text-lg mb-6">{{.Error}}</p> 16 + {{else}} 17 + <p class="text-error text-lg mb-6">An unexpected error occurred.</p> 18 + {{end}} 15 19 <a href="/admin" class="btn btn-primary">Back to Dashboard</a> 16 20 </div> 17 21 </main>
+4 -4
pkg/hold/admin/templates/pages/login.html
··· 6 6 <title>Login - Hold Admin</title> 7 7 </head> 8 8 <body class="min-h-screen flex items-center justify-center bg-base-200"> 9 - <div class="w-full max-w-sm p-4"> 9 + <main id="main-content" tabindex="-1" class="w-full max-w-sm p-4"> 10 10 <div class="card bg-base-100 shadow-xl"> 11 11 <div class="card-body"> 12 12 <h1 class="card-title justify-center text-2xl">Hold Admin</h1> ··· 14 14 15 15 {{if .Error}} 16 16 <div role="alert" class="alert alert-error mb-4"> 17 - <span>{{.Error}}</span> 17 + <span>{{loginError .Error}}</span> 18 18 </div> 19 19 {{end}} 20 20 21 - <form action="/admin/auth/oauth/authorize" method="GET"> 21 + <form action="/admin/auth/oauth/authorize" method="POST"> 22 22 <fieldset class="fieldset mb-4"> 23 23 <label class="fieldset-label" for="handle">Handle or DID</label> 24 24 <input type="text" id="handle" name="handle" ··· 41 41 <footer class="text-center mt-8 text-base-content/50 text-xs"> 42 42 <p>Hold: <code class="font-mono">{{.HoldDID}}</code></p> 43 43 </footer> 44 - </div> 44 + </main> 45 45 </body> 46 46 </html> 47 47 {{end}}
+5 -5
pkg/hold/admin/templates/partials/crew_member_row.html
··· 1 1 {{define "partials/crew_member_row.html"}} 2 2 <tr id="crew-{{.RKey}}"> 3 3 <td> 4 - <div> 5 - {{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}} 6 - <code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code> 4 + <div class="flex flex-col gap-0.5"> 5 + {{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong>{{end}} 6 + <code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.DID}}">{{.DID}}</code> 7 7 </div> 8 8 </td> 9 9 <td>{{.Role}}</td> ··· 19 19 <td> 20 20 <div class="flex flex-col gap-1 min-w-24"> 21 21 <span class="text-sm">{{.UsageHuman}}</span> 22 - <progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress> 22 + <progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100" aria-label="Storage usage for {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}: {{.UsagePercent}}%"></progress> 23 23 <small class="text-base-content/50">{{.UsagePercent}}%</small> 24 24 </div> 25 25 </td> 26 - <td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td> 26 + <td class="text-sm text-base-content/70">{{if .AddedAt.IsZero}}<span class="text-base-content/30">—</span>{{else}}{{formatTime .AddedAt}}{{end}}</td> 27 27 <td> 28 28 <div class="flex gap-1 justify-end"> 29 29 <a href="/admin/crew/{{.RKey}}" class="btn btn-ghost btn-sm btn-square" title="Edit" aria-label="Edit crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}">
+1 -1
pkg/hold/admin/templates/partials/gc_error.html
··· 1 1 {{define "partials/gc_error.html"}} 2 - <div class="alert alert-error"> 2 + <div class="alert alert-error" role="alert"> 3 3 {{ icon "alert-triangle" "size-5" }} 4 4 <span>{{.Error}}</span> 5 5 </div>
+48 -29
pkg/hold/admin/templates/partials/gc_preview.html
··· 5 5 <div class="stats shadow bg-base-100"> 6 6 <div class="stat"> 7 7 <div class="stat-title">Orphaned Records</div> 8 - <div class="stat-value {{if .Preview.OrphanedRecords}}text-error{{end}}">{{len .Preview.OrphanedRecords}}</div> 8 + <div class="stat-value {{if .Preview.OrphanedRecords}}text-error{{end}}">{{len .Preview.OrphanedRecords}}{{if .Preview.OrphanedRecords}}<span class="sr-only"> — needs attention</span>{{end}}</div> 9 9 <div class="stat-desc">Layer records with no manifest</div> 10 10 </div> 11 11 </div> 12 12 <div class="stats shadow bg-base-100"> 13 13 <div class="stat"> 14 14 <div class="stat-title">Orphaned Blobs</div> 15 - <div class="stat-value {{if .Preview.OrphanedBlobs}}text-error{{end}}">{{len .Preview.OrphanedBlobs}}</div> 15 + <div class="stat-value {{if .Preview.OrphanedBlobs}}text-error{{end}}">{{len .Preview.OrphanedBlobs}}{{if .Preview.OrphanedBlobs}}<span class="sr-only"> — needs attention</span>{{end}}</div> 16 16 <div class="stat-desc">S3 blobs with no layer record</div> 17 17 </div> 18 18 </div> 19 19 <div class="stats shadow bg-base-100"> 20 20 <div class="stat"> 21 21 <div class="stat-title">Missing Records</div> 22 - <div class="stat-value {{if .Preview.MissingRecords}}text-warning{{end}}">{{len .Preview.MissingRecords}}</div> 22 + <div class="stat-value {{if .Preview.MissingRecords}}text-warning{{end}}">{{len .Preview.MissingRecords}}{{if .Preview.MissingRecords}}<span class="sr-only"> — needs attention</span>{{end}}</div> 23 23 <div class="stat-desc">Would be reconciled</div> 24 24 </div> 25 25 </div> ··· 40 40 <!-- Orphaned Records table --> 41 41 {{if .Preview.OrphanedRecords}} 42 42 <div class="collapse collapse-arrow bg-base-100 shadow-sm"> 43 - <input type="checkbox" /> 43 + <input type="checkbox" aria-label="Show orphaned layer records" /> 44 44 <div class="collapse-title font-medium"> 45 - {{ icon "file-x" "size-4 inline" }} Orphaned Layer Records ({{len .Preview.OrphanedRecords}}) 45 + {{ icon "file-x" "size-4" }} Orphaned Layer Records ({{len .Preview.OrphanedRecords}}) 46 46 </div> 47 47 <div class="collapse-content"> 48 48 <div class="overflow-x-auto"> 49 - <table class="table table-sm"> 49 + <table class="table table-sm table-fixed"> 50 + <caption class="sr-only">Orphaned layer records</caption> 51 + <colgroup> 52 + <col style="width: 15%"> 53 + <col style="width: 25%"> 54 + <col style="width: 45%"> 55 + <col style="width: 15%"> 56 + </colgroup> 50 57 <thead> 51 58 <tr> 52 - <th>RKey</th> 53 - <th>Digest</th> 54 - <th>Manifest</th> 55 - <th>Size</th> 59 + <th scope="col">RKey</th> 60 + <th scope="col">Digest</th> 61 + <th scope="col">Manifest</th> 62 + <th scope="col">Size</th> 56 63 </tr> 57 64 </thead> 58 65 <tbody> 59 66 {{range .Preview.OrphanedRecords}} 60 67 <tr> 61 - <td><code class="text-xs font-mono">{{.Rkey}}</code></td> 62 - <td><code class="text-xs font-mono">{{truncate .Digest 24}}</code></td> 63 - <td><code class="text-xs font-mono break-all">{{truncate .ManifestURI 50}}</code></td> 68 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.Rkey}}">{{.Rkey}}</code></td> 69 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 24}}</code></td> 70 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.ManifestURI}}">{{truncate .ManifestURI 50}}</code></td> 64 71 <td class="whitespace-nowrap">{{formatBytes .Size}}</td> 65 72 </tr> 66 73 {{end}} ··· 74 81 <!-- Orphaned Blobs table --> 75 82 {{if .Preview.OrphanedBlobs}} 76 83 <div class="collapse collapse-arrow bg-base-100 shadow-sm"> 77 - <input type="checkbox" /> 84 + <input type="checkbox" aria-label="Show orphaned blobs" /> 78 85 <div class="collapse-title font-medium"> 79 - {{ icon "trash-2" "size-4 inline" }} Orphaned Blobs ({{len .Preview.OrphanedBlobs}}) 86 + {{ icon "trash-2" "size-4" }} Orphaned Blobs ({{len .Preview.OrphanedBlobs}}) 80 87 </div> 81 88 <div class="collapse-content"> 82 89 <div class="overflow-x-auto"> 83 - <table class="table table-sm"> 90 + <table class="table table-sm table-fixed"> 91 + <caption class="sr-only">Orphaned blobs</caption> 92 + <colgroup> 93 + <col style="width: 80%"> 94 + <col style="width: 20%"> 95 + </colgroup> 84 96 <thead> 85 97 <tr> 86 - <th>Digest</th> 87 - <th>Size</th> 98 + <th scope="col">Digest</th> 99 + <th scope="col">Size</th> 88 100 </tr> 89 101 </thead> 90 102 <tbody> 91 103 {{range .Preview.OrphanedBlobs}} 92 104 <tr> 93 - <td><code class="text-xs font-mono">{{truncate .Digest 30}}</code></td> 105 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 30}}</code></td> 94 106 <td class="whitespace-nowrap">{{formatBytes .Size}}</td> 95 107 </tr> 96 108 {{end}} ··· 104 116 <!-- Missing Records table (reconcile mode only) --> 105 117 {{if .Preview.MissingRecords}} 106 118 <div class="collapse collapse-arrow bg-base-100 shadow-sm"> 107 - <input type="checkbox" /> 119 + <input type="checkbox" aria-label="Show missing layer records" /> 108 120 <div class="collapse-title font-medium"> 109 - {{ icon "file-plus" "size-4 inline" }} Missing Layer Records ({{len .Preview.MissingRecords}}) 121 + {{ icon "file-plus" "size-4" }} Missing Layer Records ({{len .Preview.MissingRecords}}) 110 122 </div> 111 123 <div class="collapse-content"> 112 124 <div class="overflow-x-auto"> 113 - <table class="table table-sm"> 125 + <table class="table table-sm table-fixed"> 126 + <caption class="sr-only">Missing layer records</caption> 127 + <colgroup> 128 + <col style="width: 20%"> 129 + <col style="width: 40%"> 130 + <col style="width: 25%"> 131 + <col style="width: 15%"> 132 + </colgroup> 114 133 <thead> 115 134 <tr> 116 - <th>Digest</th> 117 - <th>Manifest</th> 118 - <th>User</th> 119 - <th>Size</th> 135 + <th scope="col">Digest</th> 136 + <th scope="col">Manifest</th> 137 + <th scope="col">User</th> 138 + <th scope="col">Size</th> 120 139 </tr> 121 140 </thead> 122 141 <tbody> 123 142 {{range .Preview.MissingRecords}} 124 143 <tr> 125 - <td><code class="text-xs font-mono">{{truncate .Digest 24}}</code></td> 126 - <td><code class="text-xs font-mono break-all">{{truncate .ManifestURI 50}}</code></td> 127 - <td><code class="text-xs font-mono">{{truncate .UserDID 24}}</code></td> 144 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 24}}</code></td> 145 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.ManifestURI}}">{{truncate .ManifestURI 50}}</code></td> 146 + <td><code class="text-xs font-mono truncate block max-w-full" title="{{.UserDID}}">{{truncate .UserDID 24}}</code></td> 128 147 <td class="whitespace-nowrap">{{formatBytes .Size}}</td> 129 148 </tr> 130 149 {{end}}
+2 -2
pkg/hold/admin/templates/partials/gc_progress.html
··· 3 3 hx-trigger="load delay:2s" 4 4 hx-target="#gc-results" 5 5 hx-swap="innerHTML"> 6 - <div class="flex items-center gap-3 p-4 bg-base-100 rounded-lg shadow-sm"> 7 - <span class="loading loading-spinner loading-md text-primary"></span> 6 + <div class="flex items-center gap-3 p-4 bg-base-100 rounded-lg shadow-sm" role="status"> 7 + <span class="loading loading-spinner loading-md text-primary" aria-hidden="true"></span> 8 8 <div> 9 9 <p class="font-medium">{{.Message}}</p> 10 10 <p class="text-sm text-base-content/50">
+10 -9
pkg/hold/admin/templates/partials/relay_crawl_result.html
··· 1 1 {{define "partials/relay_crawl_result.html"}} 2 - <tr hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}" 2 + {{/* Auto-refreshes relay status 10s after a crawl request is sent. htmx will not fire if this <tr> has been detached. */}} 3 + <tr hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}" 3 4 hx-trigger="load delay:10s" 4 5 hx-swap="outerHTML"> 5 6 <td> 6 7 {{if .Success}} 7 - <span class="badge badge-success badge-sm gap-1"> 8 + <span class="badge badge-success badge-sm gap-1" role="status"> 8 9 {{ icon "check-circle" "size-3" }} 9 10 Sent 10 11 </span> 11 12 {{else}} 12 - <span class="badge badge-error badge-sm gap-1"> 13 + <span class="badge badge-error badge-sm gap-1" role="status"> 13 14 {{ icon "alert-circle" "size-3" }} 14 15 Failed 15 16 </span> 16 17 {{end}} 17 18 </td> 18 19 <td> 19 - <div> 20 - <strong>{{.Name}}</strong><br> 21 - <code class="text-xs text-base-content/50 font-mono">{{.URL}}</code> 20 + <div class="flex flex-col gap-0.5"> 21 + <strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong> 22 + <code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code> 22 23 </div> 23 24 </td> 24 25 <td> ··· 27 28 <td> 28 29 {{if .Success}} 29 30 <span class="text-sm text-info flex items-center gap-1"> 30 - <span class="loading loading-spinner loading-xs"></span> 31 + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> 31 32 Crawl requested, refreshing... 32 33 </span> 33 34 {{else}} ··· 36 37 </td> 37 38 <td class="text-right"> 38 39 <button class="btn btn-ghost btn-sm gap-1" 39 - hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}" 40 + hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}" 40 41 hx-target="closest tr" 41 42 hx-swap="outerHTML" 42 - title="Refresh Status"> 43 + aria-label="Refresh status for {{.Name}}"> 43 44 {{ icon "refresh-ccw" "size-4" }} 44 45 Refresh 45 46 </button>
+10 -9
pkg/hold/admin/templates/partials/relay_crawl_results.html
··· 1 1 {{define "partials/relay_crawl_results.html"}} 2 2 {{range .Results}} 3 - <tr hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}" 3 + {{/* Auto-refreshes relay status 10s after crawl-all completes. htmx will not fire if this <tr> has been detached. */}} 4 + <tr hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}" 4 5 hx-trigger="load delay:10s" 5 6 hx-swap="outerHTML"> 6 7 <td> 7 8 {{if .Success}} 8 - <span class="badge badge-success badge-sm gap-1"> 9 + <span class="badge badge-success badge-sm gap-1" role="status"> 9 10 {{ icon "check-circle" "size-3" }} 10 11 Sent 11 12 </span> 12 13 {{else}} 13 - <span class="badge badge-error badge-sm gap-1"> 14 + <span class="badge badge-error badge-sm gap-1" role="status"> 14 15 {{ icon "alert-circle" "size-3" }} 15 16 Failed 16 17 </span> 17 18 {{end}} 18 19 </td> 19 20 <td> 20 - <div> 21 - <strong>{{.Name}}</strong><br> 22 - <code class="text-xs text-base-content/50 font-mono">{{.URL}}</code> 21 + <div class="flex flex-col gap-0.5"> 22 + <strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong> 23 + <code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code> 23 24 </div> 24 25 </td> 25 26 <td> ··· 28 29 <td> 29 30 {{if .Success}} 30 31 <span class="text-sm text-info flex items-center gap-1"> 31 - <span class="loading loading-spinner loading-xs"></span> 32 + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> 32 33 Crawl requested, refreshing... 33 34 </span> 34 35 {{else}} ··· 37 38 </td> 38 39 <td class="text-right"> 39 40 <button class="btn btn-ghost btn-sm gap-1" 40 - hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}" 41 + hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}" 41 42 hx-target="closest tr" 42 43 hx-swap="outerHTML" 43 - title="Refresh Status"> 44 + aria-label="Refresh status for {{.Name}}"> 44 45 {{ icon "refresh-ccw" "size-4" }} 45 46 Refresh 46 47 </button>
+7 -7
pkg/hold/admin/templates/partials/relay_status.html
··· 2 2 <tr> 3 3 <td> 4 4 {{if .Online}} 5 - <span class="badge badge-success badge-sm gap-1"> 5 + <span class="badge badge-success badge-sm gap-1" role="status"> 6 6 {{ icon "check-circle" "size-3" }} 7 7 Online 8 8 </span> 9 9 {{else}} 10 - <span class="badge badge-error badge-sm gap-1"> 10 + <span class="badge badge-error badge-sm gap-1" role="status"> 11 11 {{ icon "alert-circle" "size-3" }} 12 12 Offline 13 13 </span> 14 14 {{end}} 15 15 </td> 16 16 <td> 17 - <div> 18 - <strong>{{.Name}}</strong><br> 19 - <code class="text-xs text-base-content/50 font-mono">{{.URL}}</code> 17 + <div class="flex flex-col gap-0.5"> 18 + <strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong> 19 + <code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code> 20 20 </div> 21 21 </td> 22 22 <td> ··· 55 55 <td class="text-right"> 56 56 {{if and .Online .HasRequestCrawl}} 57 57 <button class="btn btn-ghost btn-sm gap-1" 58 - hx-post="/admin/relays/crawl?url={{.URL}}&name={{.Name}}" 58 + hx-post="/admin/relays/crawl?url={{.URL | urlquery}}&name={{.Name | urlquery}}" 59 59 hx-target="closest tr" 60 60 hx-swap="outerHTML" 61 - title="Request Crawl"> 61 + aria-label="Request crawl from {{.Name}}"> 62 62 {{ icon "refresh-ccw" "size-4" }} 63 63 Request Crawl 64 64 </button>
+26 -16
pkg/hold/admin/templates/partials/tab_crew.html
··· 22 22 {{if .Crew}} 23 23 <div class="card bg-base-100 shadow-sm"> 24 24 <div class="overflow-x-auto"> 25 - <table class="table table-zebra"> 25 + <table class="table table-zebra table-fixed"> 26 + <caption class="sr-only">Crew members</caption> 27 + <colgroup> 28 + <col style="width: 22%"> 29 + <col style="width: 10%"> 30 + <col style="width: 18%"> 31 + <col style="width: 14%"> 32 + <col style="width: 16%"> 33 + <col style="width: 12%"> 34 + <col style="width: 8%"> 35 + </colgroup> 26 36 <thead> 27 37 <tr> 28 - <th>Member</th> 29 - <th>Role</th> 30 - <th>Permissions</th> 31 - <th>Tier</th> 32 - <th>Usage</th> 33 - <th>Added</th> 34 - <th class="text-right">Actions</th> 38 + <th scope="col">Member</th> 39 + <th scope="col">Role</th> 40 + <th scope="col">Permissions</th> 41 + <th scope="col">Tier</th> 42 + <th scope="col">Usage</th> 43 + <th scope="col">Added</th> 44 + <th scope="col" class="text-right">Actions</th> 35 45 </tr> 36 46 </thead> 37 47 <tbody id="crew-list"> ··· 39 49 <tr id="crew-{{.RKey}}" 40 50 hx-get="/admin/api/crew/member?rkey={{.RKey}}" 41 51 hx-trigger="load" 42 - hx-swap="outerHTML"> 52 + hx-swap="outerHTML" 53 + aria-busy="true"> 43 54 <td> 44 - <div> 45 - <span class="loading loading-spinner loading-xs"></span> 46 - <br> 47 - <code class="text-xs text-base-content/50 break-all font-mono">{{truncate .DID 32}}</code> 55 + <div class="flex flex-col gap-0.5"> 56 + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> 57 + <code class="text-xs text-base-content/50 font-mono truncate max-w-full" title="{{.DID}}">{{truncate .DID 32}}</code> 48 58 </div> 49 59 </td> 50 60 <td>{{.Role}}</td> ··· 60 70 <td> 61 71 <div class="flex flex-col gap-1 min-w-24"> 62 72 <span class="text-sm">{{.UsageHuman}}</span> 63 - <progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress> 73 + <progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100" aria-label="Storage usage for {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}: {{.UsagePercent}}%"></progress> 64 74 <small class="text-base-content/50">{{.UsagePercent}}%</small> 65 75 </div> 66 76 </td> 67 - <td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td> 77 + <td class="text-sm text-base-content/70">{{if .AddedAt.IsZero}}<span class="text-base-content/30">—</span>{{else}}{{formatTime .AddedAt}}{{end}}</td> 68 78 <td></td> 69 79 </tr> 70 80 {{end}} ··· 73 83 </div> 74 84 </div> 75 85 {{else}} 76 - <div class="text-center py-12 text-base-content/60"> 86 + <div role="status" aria-live="polite" class="text-center py-12 text-base-content/60"> 77 87 <p>No crew members yet. <a href="/admin/crew/add" class="link link-primary">Add your first crew member</a>.</p> 78 88 </div> 79 89 {{end}}
+3 -2
pkg/hold/admin/templates/partials/tab_dashboard.html
··· 11 11 <div class="stat-value">{{.Stats.TotalCrewMembers}}</div> 12 12 </div> 13 13 </div> 14 - <div class="stats shadow bg-base-100" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML"> 14 + <div class="stats shadow bg-base-100" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML" aria-live="polite" aria-busy="true"> 15 15 <div class="stat"> 16 16 <div class="stat-title">Storage</div> 17 17 <div class="stat-value text-base-content/30 italic text-lg">Loading...</div> ··· 24 24 <h2 class="card-title text-lg">Tier Distribution</h2> 25 25 {{if .Stats.TierDistribution}} 26 26 <div class="flex flex-col gap-2"> 27 + {{/* TODO: sort tiers in handler (getDashboardStats) — Go map iteration is non-deterministic */}} 27 28 {{range $tier, $count := .Stats.TierDistribution}} 28 29 <div class="flex justify-between items-center p-3 bg-base-200 rounded-lg"> 29 30 <span class="font-medium">{{$tier}}</span> ··· 40 41 <div class="card bg-base-100 shadow-sm"> 41 42 <div class="card-body"> 42 43 <h2 class="card-title text-lg">Top Users by Storage</h2> 43 - <div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML"> 44 + <div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML" aria-live="polite" aria-busy="true"> 44 45 <p class="text-base-content/50 italic">Loading top users...</p> 45 46 </div> 46 47 </div>
+23 -14
pkg/hold/admin/templates/partials/tab_relays.html
··· 13 13 14 14 <div id="crawl-loading" class="htmx-indicator mb-4"> 15 15 <div class="flex items-center gap-3 p-4 bg-base-200 rounded-lg"> 16 - <span class="loading loading-spinner loading-md text-primary"></span> 16 + <span class="loading loading-spinner loading-md text-primary" aria-hidden="true"></span> 17 17 <div> 18 18 <p class="font-medium">Requesting crawl from all relays...</p> 19 19 <p class="text-sm text-base-content/50">This may take a few seconds.</p> ··· 23 23 24 24 <div class="card bg-base-100 shadow-sm"> 25 25 <div class="overflow-x-auto"> 26 - <table class="table"> 26 + <table class="table table-fixed"> 27 + <caption class="sr-only">Relay status</caption> 28 + <colgroup> 29 + <col style="width: 10%"> 30 + <col style="width: 25%"> 31 + <col style="width: 30%"> 32 + <col style="width: 25%"> 33 + <col style="width: 10%"> 34 + </colgroup> 27 35 <thead> 28 36 <tr> 29 - <th class="w-16"></th> 30 - <th>Relay</th> 31 - <th>Capabilities</th> 32 - <th>Hold Status</th> 33 - <th class="text-right">Actions</th> 37 + <th scope="col" class="w-16"><span class="sr-only">Status</span></th> 38 + <th scope="col">Relay</th> 39 + <th scope="col">Capabilities</th> 40 + <th scope="col">Hold Status</th> 41 + <th scope="col" class="text-right">Actions</th> 34 42 </tr> 35 43 </thead> 36 - <tbody id="relay-tbody"> 44 + <tbody id="relay-tbody" aria-live="polite"> 37 45 {{range .Relays}} 38 - <tr hx-get="/admin/api/relay/status?url={{.URL}}&name={{.Name}}" 46 + <tr hx-get="/admin/api/relay/status?url={{.URL | urlquery}}&name={{.Name | urlquery}}" 39 47 hx-trigger="load" 40 - hx-swap="outerHTML"> 48 + hx-swap="outerHTML" 49 + aria-busy="true"> 41 50 <td> 42 - <span class="loading loading-spinner loading-xs"></span> 51 + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> 43 52 </td> 44 53 <td> 45 - <div> 46 - <strong>{{.Name}}</strong><br> 47 - <code class="text-xs text-base-content/50 font-mono">{{.URL}}</code> 54 + <div class="flex flex-col gap-0.5"> 55 + <strong class="truncate max-w-[20ch]" title="{{.Name}}">{{.Name}}</strong> 56 + <code class="text-xs text-base-content/50 font-mono truncate max-w-[24ch]" title="{{.URL}}">{{.URL}}</code> 48 57 </div> 49 58 </td> 50 59 <td class="text-base-content/30 text-sm">...</td>
+1 -1
pkg/hold/admin/templates/partials/tab_settings.html
··· 3 3 <h1 class="text-2xl font-bold">Hold Settings</h1> 4 4 </div> 5 5 6 - <form action="/admin/settings/update" method="POST" class="space-y-6"> 6 + <form hx-post="/admin/settings/update" hx-swap="none" class="space-y-6"> 7 7 <div class="card bg-base-100 shadow-sm"> 8 8 <div class="card-body"> 9 9 <h2 class="card-title text-lg">Access Control</h2>
+2 -2
pkg/hold/admin/templates/partials/tab_storage.html
··· 16 16 17 17 {{if .Running}} 18 18 <span class="badge badge-warning gap-1"> 19 - <span class="loading loading-spinner loading-xs"></span> 19 + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> 20 20 Running 21 21 </span> 22 22 {{end}} ··· 52 52 </button> 53 53 </div> 54 54 55 - <div id="gc-results"> 55 + <div id="gc-results" aria-live="polite"> 56 56 {{if .Running}} 57 57 <div hx-get="/admin/api/gc/status" 58 58 hx-trigger="load delay:2s"
+13 -7
pkg/hold/admin/templates/partials/top_users.html
··· 1 1 {{define "partials/top_users.html"}} 2 2 {{if .Users}} 3 3 <div class="overflow-x-auto"> 4 - <table class="table table-zebra table-sm"> 4 + <table class="table table-zebra table-sm table-fixed"> 5 + <caption class="sr-only">Top users by storage</caption> 6 + <colgroup> 7 + <col style="width: 55%"> 8 + <col style="width: 25%"> 9 + <col style="width: 20%"> 10 + </colgroup> 5 11 <thead> 6 12 <tr> 7 - <th>Member</th> 8 - <th>Usage</th> 9 - <th>Blobs</th> 13 + <th scope="col">Member</th> 14 + <th scope="col">Usage</th> 15 + <th scope="col">Blobs</th> 10 16 </tr> 11 17 </thead> 12 18 <tbody> 13 19 {{range .Users}} 14 20 <tr> 15 21 <td> 16 - <div> 17 - {{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}} 18 - <code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code> 22 + <div class="flex flex-col gap-0.5"> 23 + {{if .Handle}}<strong class="text-base-content truncate max-w-full" title="{{.Handle}}">{{.Handle}}</strong>{{end}} 24 + <code class="text-xs text-base-content/50 font-mono truncate max-w-[20ch] block" title="{{.DID}}">{{.DID}}</code> 19 25 </div> 20 26 </td> 21 27 <td>{{.UsageHuman}}</td>