diff --git a/pkg/hold/admin/admin.go b/pkg/hold/admin/admin.go index f3fe7eb..560750f 100644 --- a/pkg/hold/admin/admin.go +++ b/pkg/hold/admin/admin.go @@ -6,6 +6,7 @@ package admin //go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'" import ( + "bytes" "context" "crypto/rand" "embed" @@ -54,12 +55,24 @@ func DefaultAdminConfig() AdminConfig { } } -// AdminSession represents an authenticated admin session +// AdminSession represents an authenticated admin session. UserAgent and +// IPPrefix are captured at login and rechecked on every request — a stolen +// token replayed from a different browser or network prefix is rejected and +// the session is torn down. Binding at /24 (IPv4) / /64 (IPv6) tolerates +// DHCP renewals within a prefix without inviting cross-network replay. type AdminSession struct { - DID string - Handle string + DID string + Handle string + CSRFToken string + CreatedAt time.Time + UserAgent string + IPPrefix string } +// sessionTTL is the server-side lifetime of an admin session. Sessions older +// than this are treated as expired regardless of cookie state. +const sessionTTL = 24 * time.Hour + // AdminUI manages the admin web interface type AdminUI struct { pds *pds.HoldPDS @@ -69,6 +82,11 @@ type AdminUI struct { templates map[string]*template.Template config AdminConfig + // secureCookies indicates cookies should carry the Secure flag regardless + // of per-request proxy header state. Set at init from PublicURL scheme so + // a misconfigured reverse proxy can't silently drop Secure. + secureCookies bool + // In-memory session storage (single user, no persistence needed) sessions map[string]*AdminSession sessionsMu sync.RWMutex @@ -139,13 +157,14 @@ func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manag } ui := &AdminUI{ - pds: holdPDS, - quotaMgr: quotaMgr, - gc: garbageCollector, - clientApp: clientApp, - templates: templates, - config: cfg, - sessions: make(map[string]*AdminSession), + pds: holdPDS, + quotaMgr: quotaMgr, + gc: garbageCollector, + clientApp: clientApp, + templates: templates, + config: cfg, + secureCookies: strings.HasPrefix(cfg.PublicURL, "https://"), + sessions: make(map[string]*AdminSession), } slog.Info("Admin panel initialized", "publicURL", cfg.PublicURL) @@ -155,24 +174,49 @@ func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manag // Session management -func (ui *AdminUI) createSession(did, handle string) (string, error) { +func (ui *AdminUI) createSession(did, handle, userAgent, ipPrefix string) (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("failed to create session token: %w", err) } token := base64.URLEncoding.EncodeToString(b) + csrfToken, err := generateCSRFToken() + if err != nil { + return "", err + } + ui.sessionsMu.Lock() - ui.sessions[token] = &AdminSession{DID: did, Handle: handle} + ui.sessions[token] = &AdminSession{ + DID: did, + Handle: handle, + CSRFToken: csrfToken, + CreatedAt: time.Now(), + UserAgent: userAgent, + IPPrefix: ipPrefix, + } ui.sessionsMu.Unlock() return token, nil } +// getSession returns the session for the given token, or nil if missing or +// expired. Expired sessions are evicted on access to keep the in-memory map +// bounded even if the user never hits logout. func (ui *AdminUI) getSession(token string) *AdminSession { ui.sessionsMu.RLock() - defer ui.sessionsMu.RUnlock() - return ui.sessions[token] + session := ui.sessions[token] + ui.sessionsMu.RUnlock() + if session == nil { + return nil + } + if !session.CreatedAt.IsZero() && time.Since(session.CreatedAt) > sessionTTL { + ui.sessionsMu.Lock() + delete(ui.sessions, token) + ui.sessionsMu.Unlock() + return nil + } + return session } func (ui *AdminUI) deleteSession(token string) { @@ -185,15 +229,31 @@ func (ui *AdminUI) deleteSession(token string) { const sessionCookieName = "hold_admin_session" +// secureForRequest returns whether the Secure flag should be set on admin +// cookies for this request. True if the hold is served over HTTPS (derived +// from PublicURL at init) OR the request itself is TLS-terminated or came +// through a proxy that advertised https. Union rules out the case where a +// reverse proxy forgets to set X-Forwarded-Proto — PublicURL is the source +// of truth. +func (ui *AdminUI) secureForRequest(r *http.Request) bool { + return ui.secureCookies || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" +} + func (ui *AdminUI) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { - secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" + // SameSite=Lax (not Strict). Strict would drop the session cookie on + // the redirect chain coming back from the OAuth provider: the browser + // classifies the post-callback navigation to /admin as not-same-site + // because the chain was initiated from the PDS, and the user lands + // back on the login page. Lax still blocks the dominant CSRF vectors + // (cross-site form POSTs, image/XHR requests) and the CSRF middleware + // covers what Lax doesn't. http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/admin", MaxAge: 86400, // 24 hours HttpOnly: true, - Secure: secure, + Secure: ui.secureForRequest(r), SameSite: http.SameSiteLaxMode, }) } @@ -217,6 +277,39 @@ func getSessionCookie(r *http.Request) (string, bool) { return cookie.Value, true } +// clientIPPrefix returns a stable prefix key for the request's client IP. +// /24 for IPv4, /64 for IPv6. Returns empty string if the address is +// unparseable — callers treat "" as "don't bind" to avoid locking users out +// behind unusual proxies (Unix sockets, tests, etc.). +func clientIPPrefix(r *http.Request) string { + var host string + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + // Leftmost entry is the original client. + if comma := strings.IndexByte(fwd, ','); comma >= 0 { + host = strings.TrimSpace(fwd[:comma]) + } else { + host = strings.TrimSpace(fwd) + } + } else { + h, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + host = h + } else { + host = r.RemoteAddr + } + } + ip := net.ParseIP(host) + if ip == nil { + return "" + } + if v4 := ip.To4(); v4 != nil { + return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2]) + } + v6 := ip.To16() + return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x", + v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7]) +} + // parseTemplates loads and parses all HTML templates. // Components (including layout) are parsed into a base template. Each page and // partial gets its own clone of the base so that {{block}} overrides don't conflict. @@ -258,6 +351,13 @@ func parseTemplates() (map[string]*template.Template, error) { template.HTMLEscapeString(name), )) }, + // csrfInput emits a hidden input carrying the per-session CSRF token. + // Usage: {{ csrfInput .CSRFToken }} + "csrfInput": csrfInputHTML, + // loginError maps a slug from the login error query parameter to a + // user-friendly message. Unknown slugs produce a generic fallback so + // internal error details are never surfaced to the browser. + "loginError": loginErrorMessage, } // Collect template files by category @@ -358,14 +458,19 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) { // OAuth client metadata endpoint (required for production OAuth) r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata) - // Public auth routes + // Public auth routes. Authorize is POST-only — the handle is the user's + // identity and must not land in browser history, access logs, or + // Referer headers. r.Get("/admin/auth/login", ui.handleLogin) - r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize) + r.Post("/admin/auth/oauth/authorize", ui.handleAuthorize) r.Get("/admin/auth/oauth/callback", ui.handleCallback) // Protected routes (require owner) r.Group(func(r chi.Router) { r.Use(ui.requireOwner) + // CSRF check runs after requireOwner so the session (and thus the + // per-session token to compare against) is already on the context. + r.Use(ui.requireCSRF) // Single admin page (client-side tab switching) r.Get("/admin", ui.handleAdmin) @@ -440,11 +545,20 @@ func (ui *AdminUI) handleClientMetadata(w http.ResponseWriter, r *http.Request) metadata.ClientName = &clientName metadata.ClientURI = &ui.config.PublicURL + // Encode into a buffer first so an encode failure can produce a clean + // 500 response. Writing directly to w commits the 200 header at the + // first byte, after which WriteHeader becomes a no-op. + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(metadata); err != nil { + slog.Error("failed to encode client metadata", "error", err, "path", r.URL.Path) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") - if err := json.NewEncoder(w).Encode(metadata); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) + if _, err := w.Write(buf.Bytes()); err != nil { + slog.Debug("client metadata write failed", "error", err, "path", r.URL.Path) } } diff --git a/pkg/hold/admin/auth.go b/pkg/hold/admin/auth.go index e8e7cb1..a1acc14 100644 --- a/pkg/hold/admin/auth.go +++ b/pkg/hold/admin/auth.go @@ -7,7 +7,14 @@ import ( "strings" ) -// requireOwner middleware ensures the request is from the hold owner +// requireOwner middleware ensures the request is from the hold owner. +// Enforces three layers: +// 1. Session token exists and has not expired (24h absolute TTL). +// 2. Session's DID still matches captain.Owner in the PDS — if ownership +// changes mid-session, the old session is torn down on the next hit. +// 3. Browser User-Agent and network prefix match what was captured at +// login. A mismatch means the cookie is being replayed from a +// different browser or network and the session is invalidated. func (ui *AdminUI) requireOwner(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Get session cookie @@ -43,6 +50,30 @@ func (ui *AdminUI) requireOwner(next http.Handler) http.Handler { return } + // User-Agent / IP-prefix binding. Empty bound values (e.g. from + // clients with no UA, requests over Unix sockets) are not + // compared — binding opts out rather than locking the user out. + if session.UserAgent != "" && session.UserAgent != r.UserAgent() { + slog.Warn("Admin session User-Agent mismatch — suspected token replay", + "did", session.DID) + ui.deleteSession(token) + clearSessionCookie(w) + http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound) + return + } + if session.IPPrefix != "" { + if now := clientIPPrefix(r); now != "" && now != session.IPPrefix { + slog.Warn("Admin session IP prefix mismatch — suspected token replay", + "did", session.DID, + "sessionPrefix", session.IPPrefix, + "requestPrefix", now) + ui.deleteSession(token) + clearSessionCookie(w) + http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound) + return + } + } + // Add session to context for handlers ctx := context.WithValue(r.Context(), adminContextKey{}, session) next.ServeHTTP(w, r.WithContext(ctx)) @@ -58,6 +89,15 @@ func getSessionFromContext(ctx context.Context) *AdminSession { return session } +// sessionDIDFromContext returns the authenticated DID for log annotation, +// empty if there is no session (e.g. an early-rejected request). +func sessionDIDFromContext(ctx context.Context) string { + if s := getSessionFromContext(ctx); s != nil { + return s.DID + } + return "" +} + // PageData contains common data for all admin pages type PageData struct { Title string @@ -65,6 +105,10 @@ type PageData struct { User *AdminSession HoldDID string Flash *Flash + // CSRFToken is the per-session token that forms and htmx must echo back + // on state-mutating requests. Threaded to at the + // layout level; plain forms emit it via {{ csrfInput .CSRFToken }}. + CSRFToken string } // Flash represents a flash message @@ -78,12 +122,18 @@ func (ui *AdminUI) newPageData(r *http.Request, title, activePage string) PageDa session := getSessionFromContext(r.Context()) flash := getFlash(r, ui) + var csrf string + if session != nil { + csrf = session.CSRFToken + } + return PageData{ Title: title, ActivePage: activePage, User: session, HoldDID: ui.pds.DID(), Flash: flash, + CSRFToken: csrf, } } diff --git a/pkg/hold/admin/csrf.go b/pkg/hold/admin/csrf.go new file mode 100644 index 0000000..d8c3d9e --- /dev/null +++ b/pkg/hold/admin/csrf.go @@ -0,0 +1,112 @@ +package admin + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "html/template" + "log/slog" + "net/http" + "strings" +) + +const ( + csrfHeaderName = "X-CSRF-Token" + csrfFormField = "csrf_token" +) + +// generateCSRFToken returns a cryptographically random token. +// 32 bytes (256 bits) base64url-encoded, matching the session token format. +func generateCSRFToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate csrf token: %w", err) + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// requireCSRF validates a per-session CSRF token on state-mutating requests. +// Safe methods (GET/HEAD/OPTIONS) are unchecked; everything else must supply +// the token via the X-CSRF-Token header (htmx path) or the csrf_token form +// field on application/x-www-form-urlencoded bodies (plain-form path). +// Multipart bodies are rejected unless the header is present — this avoids +// consuming a multipart body in middleware and stepping on per-handler size +// limits such as http.MaxBytesReader. +// +// Must run after requireOwner so a session is present on the request context. +func (ui *AdminUI) requireCSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + + session := getSessionFromContext(r.Context()) + if session == nil || session.CSRFToken == "" { + slog.Warn("CSRF check failed: no session token", + "path", r.URL.Path, "method", r.Method) + csrfReject(w, r, "missing session") + return + } + + got := r.Header.Get(csrfHeaderName) + if got == "" { + contentType := r.Header.Get("Content-Type") + // Split off any ;boundary=... suffix before comparing. + if idx := strings.IndexByte(contentType, ';'); idx >= 0 { + contentType = contentType[:idx] + } + contentType = strings.TrimSpace(strings.ToLower(contentType)) + switch contentType { + case "application/x-www-form-urlencoded": + if err := r.ParseForm(); err == nil { + got = r.PostFormValue(csrfFormField) + } + case "multipart/form-data": + // Parse multipart with a small limit just to read the CSRF + // field. 32 KB is enough for the token + file metadata without + // loading uploaded file data into memory — Go stores the file + // portion beyond maxMemory on disk. + const csrfMultipartMaxMem = 32 << 10 // 32 KB + if err := r.ParseMultipartForm(csrfMultipartMaxMem); err == nil { + got = r.FormValue(csrfFormField) + } + } + } + + if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 { + slog.Warn("CSRF token mismatch", + "path", r.URL.Path, + "method", r.Method, + "did", session.DID, + "provided", got != "") + csrfReject(w, r, "token mismatch") + return + } + next.ServeHTTP(w, r) + }) +} + +// csrfReject returns a 403. For htmx requests it surfaces a toast via the +// standard HX-Trigger channel so the page-level error handler can announce +// the failure; for plain browsers it's a text response. +func csrfReject(w http.ResponseWriter, r *http.Request, reason string) { + const userMsg = "Session expired or CSRF token invalid — reload the page and try again." + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Trigger", + `{"toast":{"message":"`+userMsg+`","type":"error"}}`) + w.Header().Set("HX-Reswap", "none") + w.WriteHeader(http.StatusForbidden) + return + } + http.Error(w, "Forbidden: "+userMsg, http.StatusForbidden) +} + +// csrfInputHTML returns a hidden form input carrying the CSRF token, safely +// escaped for attribute context. +func csrfInputHTML(token string) template.HTML { + escaped := template.HTMLEscapeString(token) + return template.HTML(``) +} diff --git a/pkg/hold/admin/errors.go b/pkg/hold/admin/errors.go new file mode 100644 index 0000000..c913392 --- /dev/null +++ b/pkg/hold/admin/errors.go @@ -0,0 +1,37 @@ +package admin + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +// renderHTMXError sends an error response suitable for htmx. For htmx +// requests it sets HX-Trigger so the client fires a toast; the global +// htmx:responseError listener in main.js is the fallback for non-triggering +// handlers. For plain browsers it degrades to http.Error. serverErr is +// logged but never exposed — pass userMsg for anything user-visible. +func renderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) { + if serverErr != nil { + slog.Error("admin htmx handler error", + "path", r.URL.Path, + "status", status, + "err", serverErr, + ) + } + if userMsg == "" { + userMsg = http.StatusText(status) + } + if r.Header.Get("HX-Request") == "true" { + trigger := map[string]map[string]string{ + "toast": {"message": userMsg, "type": "error"}, + } + if b, err := json.Marshal(trigger); err == nil { + w.Header().Set("HX-Trigger", string(b)) + } + w.Header().Set("HX-Reswap", "none") + w.WriteHeader(status) + return + } + http.Error(w, userMsg, status) +} diff --git a/pkg/hold/admin/flash.go b/pkg/hold/admin/flash.go index a22bc81..26eb79a 100644 --- a/pkg/hold/admin/flash.go +++ b/pkg/hold/admin/flash.go @@ -8,8 +8,22 @@ import ( const flashCookieName = "hold_admin_flash" -// setFlash sets a flash message cookie +// validFlashCategories bounds what can appear in alert class interpolation. +// Anything outside this set is coerced to "info" before the cookie is set. +var validFlashCategories = map[string]bool{ + "success": true, + "error": true, + "warning": true, + "info": true, +} + +// setFlash sets a flash message cookie. Category is coerced to "info" if +// not on the known allowlist — the value flows into an HTML class attribute +// in layout.html and must not carry arbitrary text. func setFlash(w http.ResponseWriter, r *http.Request, category, message string) { + if !validFlashCategories[category] { + category = "info" + } flash := Flash{ Category: category, Message: message, @@ -30,11 +44,15 @@ func setFlash(w http.ResponseWriter, r *http.Request, category, message string) MaxAge: 60, // 1 minute - should be consumed on next page load HttpOnly: true, Secure: secure, - SameSite: http.SameSiteLaxMode, + SameSite: http.SameSiteStrictMode, }) } -// getFlash retrieves and clears the flash message +// getFlash retrieves the flash message. Callers that render the flash into +// the page layout should arrange for the cookie to be cleared after display +// via clearFlash — handlers typically defer clearFlash(w) at entry. +// getFlash also rejects flashes whose category has drifted off the +// allowlist (e.g. a forged or pre-upgrade cookie). func getFlash(r *http.Request, ui *AdminUI) *Flash { cookie, err := r.Cookie(flashCookieName) if err != nil { @@ -51,6 +69,10 @@ func getFlash(r *http.Request, ui *AdminUI) *Flash { return nil } + if !validFlashCategories[flash.Category] { + flash.Category = "info" + } + return &flash } @@ -62,6 +84,6 @@ func clearFlash(w http.ResponseWriter) { Path: "/admin", MaxAge: -1, HttpOnly: true, - SameSite: http.SameSiteLaxMode, + SameSite: http.SameSiteStrictMode, }) } diff --git a/pkg/hold/admin/handlers.go b/pkg/hold/admin/handlers.go index 3dfe801..dc4eb42 100644 --- a/pkg/hold/admin/handlers.go +++ b/pkg/hold/admin/handlers.go @@ -20,12 +20,7 @@ type DashboardStats struct { // handleAdmin renders the single admin page with client-side tab switching func (ui *AdminUI) handleAdmin(w http.ResponseWriter, r *http.Request) { defer clearFlash(w) - data := struct { - PageData - }{ - PageData: ui.newPageData(r, "Hold Admin", ""), - } - ui.renderTemplate(w, "pages/admin.html", data) + ui.renderTemplate(w, "pages/admin.html", ui.newPageData(r, "Hold Admin", "")) } // getDashboardStats returns dashboard statistics @@ -58,6 +53,7 @@ func (ui *AdminUI) getDashboardStats(ctx context.Context) DashboardStats { // handleDashboardTab returns the dashboard tab content (HTMX partial) func (ui *AdminUI) handleDashboardTab(w http.ResponseWriter, r *http.Request) { + defer clearFlash(w) data := struct { Stats DashboardStats }{ diff --git a/pkg/hold/admin/handlers_auth.go b/pkg/hold/admin/handlers_auth.go index a153216..64b1b4a 100644 --- a/pkg/hold/admin/handlers_auth.go +++ b/pkg/hold/admin/handlers_auth.go @@ -8,6 +8,27 @@ import ( "atcr.io/pkg/atproto" ) +// loginErrorMessages maps slug-based error codes (emitted as query params by the +// auth handlers) to user-friendly sentences. Any slug not in this map falls back +// to a generic message so internal details are never exposed in the browser. +var loginErrorMessages = map[string]string{ + "handle_required": "Please enter a handle or DID to sign in.", + "handle_invalid": "That handle or DID could not be found. Check the spelling and try again.", + "oauth_failed": "Couldn't start the sign-in flow. Please try again.", + "oauth_callback_failed": "Sign-in was not completed. Please try again.", + "ownership_check_failed": "Couldn't verify hold ownership. Please try again.", + "access_denied": "Only the hold owner can access the admin panel.", +} + +// loginErrorMessage translates a slug into a human-readable login error. +// It is registered as the "loginError" template function. +func loginErrorMessage(slug string) string { + if msg, ok := loginErrorMessages[slug]; ok { + return msg + } + return "Couldn't sign you in. Please try again." +} + // handleLogin renders the login page func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) { // If already logged in, redirect to dashboard @@ -36,11 +57,12 @@ func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) { ui.renderTemplate(w, "pages/login.html", data) } -// handleAuthorize starts the OAuth flow +// handleAuthorize starts the OAuth flow. +// Accepts both GET (direct URL) and POST (login form submission). func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) { - handle := strings.TrimSpace(r.URL.Query().Get("handle")) + handle := strings.TrimSpace(r.FormValue("handle")) if handle == "" { - http.Redirect(w, r, "/admin/auth/login?error=Handle+is+required", http.StatusFound) + http.Redirect(w, r, "/admin/auth/login?error=handle_required", http.StatusFound) return } @@ -51,7 +73,7 @@ func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) { did, _, _, err := atproto.ResolveIdentity(r.Context(), handle) if err != nil { slog.Warn("Failed to resolve handle for admin login", "handle", handle, "error", err) - http.Redirect(w, r, "/admin/auth/login?error=Could+not+resolve+handle", http.StatusFound) + http.Redirect(w, r, "/admin/auth/login?error=handle_invalid", http.StatusFound) return } @@ -61,7 +83,7 @@ func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) { authURL, err := ui.clientApp.StartAuthFlow(r.Context(), did) if err != nil { slog.Error("Failed to start OAuth flow", "error", err) - http.Redirect(w, r, "/admin/auth/login?error=OAuth+initialization+failed", http.StatusFound) + http.Redirect(w, r, "/admin/auth/login?error=oauth_failed", http.StatusFound) return } @@ -76,7 +98,7 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { sessionData, err := ui.clientApp.ProcessCallback(ctx, r.URL.Query()) if err != nil { slog.Error("OAuth callback failed", "error", err) - http.Redirect(w, r, "/admin/auth/login?error=OAuth+authentication+failed", http.StatusFound) + http.Redirect(w, r, "/admin/auth/login?error=oauth_callback_failed", http.StatusFound) return } @@ -95,7 +117,7 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { _, captain, err := ui.pds.GetCaptainRecord(ctx) if err != nil { slog.Error("Failed to get captain record during OAuth callback", "error", err) - http.Redirect(w, r, "/admin/auth/login?error=Failed+to+verify+ownership", http.StatusFound) + http.Redirect(w, r, "/admin/auth/login?error=ownership_check_failed", http.StatusFound) return } @@ -105,12 +127,14 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { "did", did, "handle", handle, "owner", captain.Owner) - http.Redirect(w, r, "/admin/auth/login?error=Access+denied:+Only+the+hold+owner+can+access+the+admin+panel", http.StatusFound) + http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound) return } - // Create session and set cookie - token, err := ui.createSession(did, handle) + // Create session and set cookie. Bind to the browser's User-Agent and + // network prefix at login so a stolen cookie replayed from a different + // browser or /24 can't be used. + token, err := ui.createSession(did, handle, r.UserAgent(), clientIPPrefix(r)) if err != nil { slog.Error("failed to create session token", "error", err, "path", r.URL.Path) http.Error(w, "Failed to create session", http.StatusInternalServerError) diff --git a/pkg/hold/admin/handlers_crew.go b/pkg/hold/admin/handlers_crew.go index 435e8cd..4c0fa53 100644 --- a/pkg/hold/admin/handlers_crew.go +++ b/pkg/hold/admin/handlers_crew.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "net/http" + "net/url" "sort" "strings" "time" @@ -54,9 +55,10 @@ type TierOption struct { // Includes usage data (fast bulk SQL query) for correct sort order. // Handles are lazy-loaded per-row via handleCrewMemberInfo. func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) { + defer clearFlash(w) crew, err := ui.pds.ListCrewMembers(r.Context()) if err != nil { - http.Error(w, "Failed to list crew: "+err.Error(), http.StatusInternalServerError) + renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load crew", err) return } @@ -307,9 +309,11 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() rkey := chi.URLParam(r, "rkey") + editURL, _ := url.JoinPath("/admin/crew/", rkey) + if err := r.ParseForm(); err != nil { setFlash(w, r, "error", "Invalid form data") - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) + http.Redirect(w, r, editURL, http.StatusFound) return } @@ -321,6 +325,16 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { return } + // Owner's crew record is immutable via the admin UI — the template + // disables the inputs but defense-in-depth requires a server-side gate + // in case the form is bypassed. + _, captain, capErr := ui.pds.GetCaptainRecord(ctx) + if capErr == nil && captain != nil && current.Member == captain.Owner { + setFlash(w, r, "error", "Owner permissions cannot be modified") + http.Redirect(w, r, "/admin#crew", http.StatusFound) + return + } + // Parse new values role := r.FormValue("role") tier := r.FormValue("tier") @@ -340,24 +354,28 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { if tier != current.Tier { if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { setFlash(w, r, "error", "Failed to update tier: "+err.Error()) - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) + http.Redirect(w, r, editURL, http.StatusFound) return } } - // For role/permissions changes, need to delete and recreate - // (ATProto records are immutable, updates require delete+create) - if role != current.Role || !slicesEqual(permissions, current.Permissions) { - // Delete old record - if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { - setFlash(w, r, "error", "Failed to update: "+err.Error()) - http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) + // For role/permissions changes, need to delete and recreate — ATProto + // records are immutable. Create-then-delete (instead of delete-then- + // create): if creation fails the old record is still there, so the + // worst outcome is a transient duplicate rather than a silently- + // deleted member. + if role != current.Role || !permissionsEqual(permissions, current.Permissions) { + if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil { + setFlash(w, r, "error", "Failed to update crew record: "+err.Error()) + http.Redirect(w, r, editURL, http.StatusFound) return } - - // Create new record with updated values (including tier) - if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil { - setFlash(w, r, "error", "Failed to recreate crew record: "+err.Error()) + if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { + slog.Error("Failed to remove old crew record after replacement", + "rkey", rkey, "member", current.Member, "error", err) + // The new record is already live; surface a non-fatal warning + // and return to the crew list. + setFlash(w, r, "warning", "Update succeeded but old record may linger: "+err.Error()) http.Redirect(w, r, "/admin#crew", http.StatusFound) return } @@ -404,9 +422,12 @@ func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) { slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID) - // For HTMX requests, return empty response (row will be removed) + // For HTMX requests, return 204 No Content. The row uses + // hx-swap="outerHTML" so htmx replaces it with the empty response body + // and the row disappears. Explicit 204 is the stable idiom (plain 200 + // with empty body works today but is implementation-defined). if r.Header.Get("HX-Request") == "true" { - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) return } @@ -438,12 +459,13 @@ func (ui *AdminUI) getTierOptions() []TierOption { return options } -// slicesEqual checks if two string slices contain the same elements -func slicesEqual(a, b []string) bool { +// permissionsEqual checks if two permission slices contain the same set of +// entries. Order-independent — permissions are an unordered set, not a list. +func permissionsEqual(a, b []string) bool { if len(a) != len(b) { return false } - aMap := make(map[string]bool) + aMap := make(map[string]bool, len(a)) for _, v := range a { aMap[v] = true } @@ -455,8 +477,17 @@ func slicesEqual(a, b []string) bool { return true } -// parseTime parses an RFC3339 timestamp +// parseTime parses an RFC3339 timestamp. Malformed values return the zero +// time so callers can use t.IsZero() as a guard; the debug log lets an +// operator trace data corruption that the UI otherwise hides. func parseTime(s string) time.Time { - t, _ := time.Parse(time.RFC3339, s) + if s == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + slog.Debug("parseTime: malformed RFC3339 timestamp", "value", s, "error", err) + return time.Time{} + } return t } diff --git a/pkg/hold/admin/handlers_crew_io.go b/pkg/hold/admin/handlers_crew_io.go index 6fb4246..6a6191f 100644 --- a/pkg/hold/admin/handlers_crew_io.go +++ b/pkg/hold/admin/handlers_crew_io.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log/slog" + "mime" "net/http" "strings" "time" @@ -62,8 +63,9 @@ func (ui *AdminUI) handleCrewExport(w http.ResponseWriter, r *http.Request) { } filename := "crew-export-" + time.Now().Format("2006-01-02") + ".json" + disposition := mime.FormatMediaType("attachment", map[string]string{"filename": filename}) w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Content-Disposition", disposition) enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/pkg/hold/admin/handlers_gc.go b/pkg/hold/admin/handlers_gc.go index ea97a0e..a3e3de5 100644 --- a/pkg/hold/admin/handlers_gc.go +++ b/pkg/hold/admin/handlers_gc.go @@ -30,6 +30,7 @@ type gcProgressData struct { // handleGCTab returns the storage/GC tab content (HTMX partial) func (ui *AdminUI) handleGCTab(w http.ResponseWriter, r *http.Request) { + defer clearFlash(w) if ui.gc == nil { ui.renderTemplate(w, "partials/tab_storage.html", gcTabData{}) return diff --git a/pkg/hold/admin/handlers_relays.go b/pkg/hold/admin/handlers_relays.go index 44a85fb..24981c0 100644 --- a/pkg/hold/admin/handlers_relays.go +++ b/pkg/hold/admin/handlers_relays.go @@ -41,8 +41,22 @@ func getRelayViews() []RelayView { return relays } +// knownRelayByURL returns the matching KnownRelay (exact URL match) or nil +// if the URL is not on the allowlist. Used to gate outbound HTTP from the +// status/crawl handlers — the `url` query parameter is attacker-controllable +// in principle, so we restrict it to vetted relay endpoints. +func knownRelayByURL(relayURL string) *atproto.KnownRelay { + for i := range atproto.KnownRelays { + if atproto.KnownRelays[i].URL == relayURL { + return &atproto.KnownRelays[i] + } + } + return nil +} + // handleRelaysTab returns the relays tab content (HTMX partial) func (ui *AdminUI) handleRelaysTab(w http.ResponseWriter, r *http.Request) { + defer clearFlash(w) data := struct { Relays []RelayView }{ @@ -54,11 +68,18 @@ func (ui *AdminUI) handleRelaysTab(w http.ResponseWriter, r *http.Request) { // handleRelayStatus returns an HTMX partial with a relay's full status row. func (ui *AdminUI) handleRelayStatus(w http.ResponseWriter, r *http.Request) { relayURL := r.URL.Query().Get("url") - relayName := r.URL.Query().Get("name") if relayURL == "" { http.Error(w, "Missing url parameter", http.StatusBadRequest) return } + known := knownRelayByURL(relayURL) + if known == nil { + slog.Warn("Admin relay status request for non-allowlisted URL", + "url", relayURL, "did", sessionDIDFromContext(r.Context())) + http.Error(w, "Unknown relay", http.StatusBadRequest) + return + } + relayName := known.Name parsed, err := url.Parse(ui.config.PublicURL) if err != nil { @@ -99,11 +120,18 @@ type RelayCrawlResultView struct { // handleRelayCrawl requests crawl from a single relay and returns an HTMX partial. func (ui *AdminUI) handleRelayCrawl(w http.ResponseWriter, r *http.Request) { relayURL := r.URL.Query().Get("url") - relayName := r.URL.Query().Get("name") if relayURL == "" { http.Error(w, "Missing relay URL", http.StatusBadRequest) return } + known := knownRelayByURL(relayURL) + if known == nil { + slog.Warn("Admin relay crawl request for non-allowlisted URL", + "url", relayURL, "did", sessionDIDFromContext(r.Context())) + http.Error(w, "Unknown relay", http.StatusBadRequest) + return + } + relayName := known.Name err := atproto.RequestCrawl(relayURL, ui.config.PublicURL) diff --git a/pkg/hold/admin/handlers_settings.go b/pkg/hold/admin/handlers_settings.go index f718cb4..f9c00ed 100644 --- a/pkg/hold/admin/handlers_settings.go +++ b/pkg/hold/admin/handlers_settings.go @@ -57,9 +57,10 @@ func (ui *AdminUI) getSettingsData(ctx context.Context) (*settingsData, error) { // handleSettingsTab returns the settings tab content (HTMX partial) func (ui *AdminUI) handleSettingsTab(w http.ResponseWriter, r *http.Request) { + defer clearFlash(w) settings, err := ui.getSettingsData(r.Context()) if err != nil { - http.Error(w, "Failed to load settings: "+err.Error(), http.StatusInternalServerError) + renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load settings", err) return } @@ -71,13 +72,30 @@ func (ui *AdminUI) handleSettingsTab(w http.ResponseWriter, r *http.Request) { ui.renderTemplate(w, "partials/tab_settings.html", data) } -// handleSettingsUpdate processes settings updates +// handleSettingsUpdate processes settings updates. Supports both plain-form +// (flash-then-redirect) and htmx (HX-Trigger toast) paths so the settings +// panel can stay in the SPA shell. func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + isHTMX := r.Header.Get("HX-Request") == "true" + + respond := func(category, msg string, status int) { + if isHTMX { + trigger := `{"toast":{"message":` + jsonString(msg) + `,"type":"` + category + `"}}` + w.Header().Set("HX-Trigger", trigger) + w.Header().Set("HX-Reswap", "none") + if status == 0 { + status = http.StatusNoContent + } + w.WriteHeader(status) + return + } + setFlash(w, r, category, msg) + http.Redirect(w, r, "/admin#settings", http.StatusFound) + } if err := r.ParseForm(); err != nil { - setFlash(w, r, "error", "Invalid form data") - http.Redirect(w, r, "/admin#settings", http.StatusFound) + respond("error", "Invalid form data", http.StatusBadRequest) return } @@ -89,8 +107,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) // Validate successor DID format if provided if successor != "" { if !atproto.IsDID(successor) || (!strings.HasPrefix(successor, "did:web:") && !strings.HasPrefix(successor, "did:plc:")) { - setFlash(w, r, "error", "Successor must be a valid did:web: or did:plc: DID") - http.Redirect(w, r, "/admin#settings", http.StatusFound) + respond("error", "Successor must be a valid did:web: or did:plc: DID", http.StatusBadRequest) return } } @@ -99,8 +116,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) _, captain, getErr := ui.pds.GetCaptainRecord(ctx) if getErr != nil { slog.Error("Failed to get captain record", "error", getErr) - setFlash(w, r, "error", "Failed to read settings: "+getErr.Error()) - http.Redirect(w, r, "/admin#settings", http.StatusFound) + respond("error", "Couldn't read settings", http.StatusInternalServerError) return } @@ -112,8 +128,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) _, err := ui.pds.UpdateCaptainRecord(ctx, captain) if err != nil { slog.Error("Failed to update captain record", "error", err) - setFlash(w, r, "error", "Failed to update settings: "+err.Error()) - http.Redirect(w, r, "/admin#settings", http.StatusFound) + respond("error", "Couldn't update settings", http.StatusInternalServerError) return } @@ -133,16 +148,44 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) // Show warning toast once, then suppress for 2 minutes if _, err := r.Cookie("config_write_warned"); err != nil { - setFlash(w, r, "warning", "Settings saved but config file is not writable — changes won't persist across restarts") setConfigWriteWarnedCookie(w, r) - http.Redirect(w, r, "/admin#settings", http.StatusFound) + respond("warning", "Saved, but config file isn't writable — changes won't persist across restarts", 0) return } } } - setFlash(w, r, "success", "Settings updated successfully") - http.Redirect(w, r, "/admin#settings", http.StatusFound) + respond("success", "Settings updated", 0) +} + +// jsonString wraps a string in double quotes with JSON-safe escaping. +// Used to build HX-Trigger header values without pulling in encoding/json. +func jsonString(s string) string { + b := make([]byte, 0, len(s)+2) + b = append(b, '"') + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '"', '\\': + b = append(b, '\\', c) + case '\n': + b = append(b, '\\', 'n') + case '\r': + b = append(b, '\\', 'r') + case '\t': + b = append(b, '\\', 't') + default: + if c < 0x20 { + b = append(b, '\\', 'u', '0', '0', + "0123456789abcdef"[c>>4], + "0123456789abcdef"[c&0xf]) + } else { + b = append(b, c) + } + } + } + b = append(b, '"') + return string(b) } // writeConfigSettings updates the toggleable settings in the YAML config file. @@ -173,6 +216,6 @@ func setConfigWriteWarnedCookie(w http.ResponseWriter, r *http.Request) { MaxAge: 120, // 2 minutes HttpOnly: true, Secure: secure, - SameSite: http.SameSiteLaxMode, + SameSite: http.SameSiteStrictMode, }) } diff --git a/pkg/hold/admin/public/js/bundle.min.js b/pkg/hold/admin/public/js/bundle.min.js index 461b717..be8cf5c 100644 --- a/pkg/hold/admin/public/js/bundle.min.js +++ b/pkg/hold/admin/public/js/bundle.min.js @@ -1 +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(/]*)?>[\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('");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=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"&&s--}a0;){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=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;r0?(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","
"),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(;n0}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;m0?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;i0;){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;a0?(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", "+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;thtmx.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=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;nt.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;a0?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.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`)}}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='Invalid DID format';return}n.innerHTML='Looking up...';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='Unsupported DID method';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=` ${f}`}else n.innerHTML='No handle found'}catch(i){n.innerHTML=`Lookup failed: ${i.message}`}}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; +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(/]*)?>[\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('");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=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"&&s--}l0;){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=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;r0?(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","
"),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(;n0}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;g0?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;i0;){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;l0?(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", "+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;thtmx.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=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;nt.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;l0?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.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`)}}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; diff --git a/pkg/hold/admin/src/css/main.css b/pkg/hold/admin/src/css/main.css index 81694f9..c1b6353 100644 --- a/pkg/hold/admin/src/css/main.css +++ b/pkg/hold/admin/src/css/main.css @@ -94,24 +94,24 @@ /* ======================================== ADDITIONAL CSS VARIABLES ======================================== */ +/* Card elevation. In dark mode, shadows disappear against a dark surface — + depth is communicated primarily by the base-100 → base-200 surface ramp. + Tinted or glow shadows read as highlights, not elevation; use neutral-black. */ :root { - --shadow-card-hover: - 0 8px 25px oklch(67.1% 0.05 145 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1); + --color-helm-light: oklch(31% 0.181 267.5); + --color-helm-dark: oklch(64.6% 0.19 273.2); } [data-theme="dark"] { --shadow-card-hover: - 0 8px 25px oklch(67.1% 0.05 145 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2); + 0 2px 4px -1px oklch(0% 0 0 / 0.55), + 0 12px 24px -6px oklch(0% 0 0 / 0.45); } [data-theme="light"] { --shadow-card-hover: - 0 8px 25px oklch(53.1% 0.1 144.8 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1); -} - -[data-theme="dark"] { - --shadow-card-hover: - 0 8px 25px oklch(63.1% 0.07 144.7 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2); + 0 1px 2px oklch(0% 0 0 / 0.06), + 0 8px 24px -6px oklch(0% 0 0 / 0.12); } /* ======================================== @@ -260,21 +260,23 @@ /* ---------------------------------------- HELM BRAND COLOR (official Helm blue #0F1689) + Tokens live on :root (--color-helm-{light,dark}) so the value is + declared once and any future brand shift updates every consumer. ---------------------------------------- */ .text-helm { - @apply text-[oklch(31%_0.181_267.5)]; + color: var(--color-helm-light); } [data-theme="dark"] .text-helm { - @apply text-[oklch(64.6%_0.19_273.2)]; + color: var(--color-helm-dark); } .badge-helm { - --badge-color: oklch(31% 0.181 267.5); + --badge-color: var(--color-helm-light); } [data-theme="dark"] .badge-helm { - --badge-color: oklch(64.6% 0.19 273.2); + --badge-color: var(--color-helm-dark); } /* ---------------------------------------- @@ -296,37 +298,9 @@ CARD EXTENSIONS ---------------------------------------- */ .card-interactive { - @apply cursor-pointer duration-500; - transition-property: box-shadow, transform; - } - - .card-interactive:hover { - box-shadow: var(--shadow-card-hover); - transform: translateY(-2px); - } - - /* ---------------------------------------- - ACTOR-TYPEAHEAD COMPONENT STYLING - ---------------------------------------- */ - actor-typeahead { - /* Use DaisyUI CSS variables - they auto-switch with theme */ - --color-background: var(--color-base-100); - --color-border: var(--color-base-300); - --color-shadow: var(--color-base-content); - --color-hover: var(--color-base-200); - --color-avatar-fallback: var(--color-base-300); - --radius: 0.5rem; - --padding-menu: 0.25rem; - z-index: 50; - } - - actor-typeahead::part(handle) { - @apply text-base-content; - } - - actor-typeahead::part(menu) { - @apply shadow-lg; - margin-top: 0.25rem; + @apply cursor-pointer; + transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 250ms cubic-bezier(0.16, 1, 0.3, 1); } /* ---------------------------------------- @@ -351,7 +325,6 @@ @apply text-base-content; } - .recent-accounts-item:hover, .recent-accounts-item.focused { @apply bg-base-200; } @@ -369,3 +342,81 @@ @apply block w-full; } } + +/* ======================================== + KEYBOARD FOCUS RING + Applied only on :focus-visible so mouse users don't see it. + Uses the primary hue so it reads as part of the Deep Ocean palette. + ======================================== */ +:where(a, button, [role="button"], [role="tab"], input, select, textarea, summary, [tabindex]):focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* ======================================== + HOVER-ONLY CARD AFFORDANCE + Guard behind pointer: fine to prevent sticky hover state on touch. + ======================================== */ +@media (hover: hover) and (pointer: fine) { + .card-interactive:hover { + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); + } + + .recent-accounts-item:hover { + @apply bg-base-200; + } +} + +/* ======================================== + TOUCH TARGET SIZING + Small buttons and compact form controls meet the keyboard minimum on + desktop but fall below the 44×44 recommended touch target on touch + devices (WCAG 2.5.5). Grow them on any device that can't reliably + produce hover — covers pure touch as well as hybrid touchscreen + laptops where `pointer: coarse` alone misses. + ======================================== */ +@media (pointer: coarse), (hover: none) { + /* Icon-only buttons grow both axes — daisyUI's circle/square variants + are the marker for these. */ + :is(.btn-circle, .btn-square):is(.btn-xs, .btn-sm) { + min-width: 2.75rem; + min-height: 2.75rem; + } + + /* Text buttons only need vertical clearance — padding handles width. */ + .btn-xs, .btn-sm { + min-height: 2.75rem; + } + + /* Small checkbox/radio: expand the clickable region without distorting + the control itself. */ + :is(.checkbox, .radio):is(.checkbox-xs, .radio-xs, .checkbox-sm, .radio-sm) { + min-width: 1.5rem; + min-height: 1.5rem; + } + + /* daisyUI menu items (used in dropdowns) are small link-like elements. + Ensure they meet the tap threshold. */ + .menu li > a, + .menu li > button { + min-height: 2.75rem; + } +} + +/* ======================================== + REDUCED MOTION + Honor users who opt out of animation. Collapse all durations to a + near-instant value rather than removing transitions entirely, so + state changes still fire (transitionend listeners, etc.). + ======================================== */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/pkg/hold/admin/src/js/main.js b/pkg/hold/admin/src/js/main.js index 63499a0..03d285a 100644 --- a/pkg/hold/admin/src/js/main.js +++ b/pkg/hold/admin/src/js/main.js @@ -2,11 +2,21 @@ import htmx from 'htmx.org'; window.htmx = htmx; +// Safe localStorage wrappers. Safari private mode, disabled storage, and +// quota-exceeded all throw from getItem/setItem — absorb those failures so +// individual features degrade silently instead of crashing the page. +function lsGet(key) { + try { return localStorage.getItem(key); } catch (_) { return null; } +} +function lsSet(key, value) { + try { localStorage.setItem(key, value); } catch (_) { /* quota/disabled */ } +} + // ======================================== // Theme management (system / light / dark) // ======================================== function getThemePreference() { - return localStorage.getItem('hold-admin-theme') || 'system'; + return lsGet('hold-admin-theme') || 'system'; } function getEffectiveTheme(pref) { @@ -26,7 +36,7 @@ function applyTheme() { } function setTheme(theme) { - localStorage.setItem('hold-admin-theme', theme); + lsSet('hold-admin-theme', theme); applyTheme(); closeThemeDropdown(); } @@ -43,6 +53,7 @@ function updateThemeUI(pref) { if (check) { check.style.visibility = isSelected ? 'visible' : 'hidden'; } + option.setAttribute('aria-checked', isSelected ? 'true' : 'false'); }); } @@ -70,14 +81,39 @@ function initDIDLookup() { if (!didInput || !lookupBtn || !handleResult) return; + // Build a simple text node inside a wrapper span — used to avoid innerHTML + // with externally-controlled strings. + function setHandleResult(className, text, iconHref) { + if (!document.contains(handleResult)) return; + handleResult.textContent = ''; + const span = document.createElement('span'); + span.className = className; + if (iconHref) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'icon size-4'); + svg.setAttribute('aria-hidden', 'true'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', iconHref); + svg.appendChild(use); + span.appendChild(svg); + span.appendChild(document.createTextNode(' ')); + const strong = document.createElement('strong'); + strong.textContent = text; + span.appendChild(strong); + } else { + span.textContent = text; + } + handleResult.appendChild(span); + } + async function lookupHandle() { const did = didInput.value.trim(); if (!did.startsWith('did:')) { - handleResult.innerHTML = 'Invalid DID format'; + setHandleResult('text-error', 'Invalid DID format'); return; } - handleResult.innerHTML = 'Looking up...'; + setHandleResult('text-base-content/50 italic', 'Looking up...'); try { let url; @@ -87,7 +123,8 @@ function initDIDLookup() { const host = did.replace('did:web:', '').replace(/%3A/g, ':'); url = `https://${host}/.well-known/did.json`; } else { - handleResult.innerHTML = 'Unsupported DID method'; + if (!document.contains(handleResult)) return; + setHandleResult('text-error', 'Unsupported DID method'); return; } @@ -95,16 +132,22 @@ function initDIDLookup() { if (!resp.ok) throw new Error('DID not found'); const doc = await resp.json(); + if (!document.contains(handleResult)) return; const aka = doc.alsoKnownAs || []; const handleUri = aka.find(u => u.startsWith('at://')); if (handleUri) { const handle = handleUri.replace('at://', ''); - handleResult.innerHTML = ` ${handle}`; + setHandleResult( + 'text-success flex items-center gap-1', + handle, + '/admin/public/icons.svg#check-circle' + ); } else { - handleResult.innerHTML = 'No handle found'; + setHandleResult('text-warning', 'No handle found'); } } catch (err) { - handleResult.innerHTML = `Lookup failed: ${err.message}`; + if (!document.contains(handleResult)) return; + setHandleResult('text-error', `Lookup failed: ${err.message}`); } } @@ -117,10 +160,82 @@ function initDIDLookup() { }); } +// ======================================== +// Toast notifications + htmx error handling +// ======================================== +// Pre-create the toast container on DOMContentLoaded so the aria-live region +// exists before any announcement. If the very first toast fires earlier +// (e.g. an htmx:responseError during initial boot) ensureToastContainer() +// constructs it lazily. +function ensureToastContainer() { + let container = document.getElementById('toast-container'); + if (container) return container; + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast toast-end toast-bottom z-50'; + container.setAttribute('aria-live', 'polite'); + container.setAttribute('aria-atomic', 'false'); + if (document.body) document.body.appendChild(container); + return container; +} + +function showToast(message, type) { + const container = ensureToastContainer(); + const isError = type === 'error'; + const alertClass = isError ? 'alert-error' : (type === 'warning' ? 'alert-warning' : 'alert-success'); + const toast = document.createElement('div'); + toast.className = `alert ${alertClass} shadow-lg transition-opacity duration-300`; + toast.setAttribute('role', isError ? 'alert' : 'status'); + const span = document.createElement('span'); + span.textContent = message; + toast.appendChild(span); + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +// Global htmx error handlers. Opt-out: any ancestor with +// data-suppress-htmx-toast skips the toast (for components that render their +// own inline error state). If the server already triggered a toast via +// HX-Trigger, skip to avoid double-firing. +document.body.addEventListener('htmx:responseError', (evt) => { + const elt = evt.detail && evt.detail.elt; + if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return; + const xhr = evt.detail && evt.detail.xhr; + const trigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger'); + if (trigger && trigger.indexOf('toast') !== -1) return; + const status = xhr ? xhr.status : 0; + const msg = status === 401 ? 'Session expired — please sign in again' + : status === 403 ? 'Not authorized' + : status === 404 ? 'Not found' + : status === 429 ? 'Too many requests — please slow down' + : status >= 500 ? 'Server error — please try again' + : 'Something went wrong'; + showToast(msg, 'error'); +}); + +document.body.addEventListener('htmx:sendError', (evt) => { + const elt = evt.detail && evt.detail.elt; + if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return; + showToast('Network error — check your connection', 'error'); +}); + +// Server-triggered toast via HX-Trigger JSON header. +// Accepts { "toast": { "message": "...", "type": "success|error|warning" } }. +document.body.addEventListener('toast', (evt) => { + const d = (evt && evt.detail) || {}; + const message = d.message || d.msg || ''; + if (!message) return; + showToast(message, d.type || 'info'); +}); + // ======================================== // Init // ======================================== document.addEventListener('DOMContentLoaded', () => { + ensureToastContainer(); applyTheme(); // Theme dropdown setup @@ -132,9 +247,20 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + // Sync aria-expanded on theme-toggle with the native
+ // open state so SR announcements match actual disclosure state. + document.querySelectorAll('[data-theme-toggle]').forEach(btn => { + const details = btn.closest('details'); + if (!details) return; + const sync = () => btn.setAttribute('aria-expanded', details.open ? 'true' : 'false'); + sync(); + details.addEventListener('toggle', sync); + }); + // DID lookup on crew add page initDIDLookup(); }); // Export for template onclick handlers window.setTheme = setTheme; +window.showToast = showToast; diff --git a/pkg/hold/admin/templates/components/head.html b/pkg/hold/admin/templates/components/head.html index cac4b5b..fdaa9d5 100644 --- a/pkg/hold/admin/templates/components/head.html +++ b/pkg/hold/admin/templates/components/head.html @@ -1,8 +1,14 @@ {{define "admin-head"}} + + + - + - {{end}} {{end}} diff --git a/pkg/hold/admin/templates/components/nav.html b/pkg/hold/admin/templates/components/nav.html index afd7cf3..1fda1e7 100644 --- a/pkg/hold/admin/templates/components/nav.html +++ b/pkg/hold/admin/templates/components/nav.html @@ -1,5 +1,5 @@ {{define "nav"}} - + {{end}} diff --git a/pkg/hold/admin/templates/components/sidebar.html b/pkg/hold/admin/templates/components/sidebar.html index 76811ff..c95f3b4 100644 --- a/pkg/hold/admin/templates/components/sidebar.html +++ b/pkg/hold/admin/templates/components/sidebar.html @@ -1,19 +1,49 @@ {{define "admin-sidebar-mobile"}} -
- + @@ -21,13 +51,18 @@ {{define "admin-sidebar"}} -