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(''+t+"");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(''+t+"");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"}}
-