From f057f169f06a676bdbafdb0ac564bc943363f485 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 21 Apr 2026 21:18:13 -0500 Subject: [PATCH] large list of ui fixes for accessibility/hardening etc. --- .tangled/workflows/lint.yaml | 2 +- .../workflows/release-credential-helper.yml | 2 +- .tangled/workflows/tests.yml | 2 +- Dockerfile.appview | 7 +- Makefile | 29 +- config-appview.example.yaml | 7 +- docs/BILLING_REFACTOR.md | 4 +- pkg/appview/config.go | 8 +- pkg/appview/handlers/base.go | 1 + pkg/appview/handlers/common.go | 4 + pkg/appview/handlers/device.go | 2 +- pkg/appview/handlers/diff.go | 62 +++- pkg/appview/handlers/digest_content.go | 120 +++++-- pkg/appview/handlers/errors.go | 32 ++ pkg/appview/handlers/home.go | 18 +- pkg/appview/handlers/image_advisor.go | 22 +- pkg/appview/handlers/legal.go | 49 ++- pkg/appview/handlers/manifest_health.go | 54 ++- pkg/appview/handlers/meta.go | 28 +- pkg/appview/handlers/repository.go | 72 ++-- pkg/appview/handlers/scan_result.go | 31 +- pkg/appview/handlers/scan_result_test.go | 17 +- pkg/appview/handlers/search.go | 157 +++++---- pkg/appview/handlers/settings.go | 333 +++++++++++------- pkg/appview/handlers/storage.go | 13 +- pkg/appview/handlers/subscription.go | 2 +- pkg/appview/handlers/user.go | 13 +- pkg/appview/handlers/vuln_details.go | 8 +- pkg/appview/handlers/webhooks.go | 42 ++- pkg/appview/public/icons.svg | 6 +- pkg/appview/public/js/bundle.min.js | 28 +- pkg/appview/public/sitemap-static.xml | 5 + pkg/appview/readme/fetcher_test.go | 104 ++++++ pkg/appview/routes/routes.go | 11 +- pkg/appview/server.go | 1 + pkg/appview/src/css/main.css | 51 ++- pkg/appview/src/js/app.js | 212 +++++++++-- pkg/appview/src/js/repository.js | 38 +- pkg/appview/src/js/sailor-typeahead.js | 36 +- pkg/appview/src/js/settings.js | 157 ++++----- .../templates/components/card-grid.html | 52 ++- .../templates/components/docker-command.html | 6 +- pkg/appview/templates/components/footer.html | 20 +- pkg/appview/templates/components/head.html | 41 ++- pkg/appview/templates/components/hero.html | 12 +- pkg/appview/templates/components/meta.html | 22 +- pkg/appview/templates/components/modal.html | 30 -- .../templates/components/nav-brand.html | 6 +- .../components/nav-theme-toggle.html | 25 +- .../templates/components/nav-user.html | 15 +- pkg/appview/templates/components/nav.html | 4 +- .../components/pull-command-switcher.html | 2 +- .../templates/components/pull-count.html | 5 +- .../templates/components/repo-avatar.html | 7 +- .../templates/components/repo-card.html | 15 +- pkg/appview/templates/components/star.html | 24 +- pkg/appview/templates/pages/404.html | 2 +- pkg/appview/templates/pages/diff.html | 38 +- pkg/appview/templates/pages/digest.html | 19 +- pkg/appview/templates/pages/home.html | 33 +- pkg/appview/templates/pages/install.html | 18 +- pkg/appview/templates/pages/learn-more.html | 8 +- pkg/appview/templates/pages/login.html | 19 +- pkg/appview/templates/pages/privacy.html | 16 +- pkg/appview/templates/pages/repository.html | 72 ++-- pkg/appview/templates/pages/search.html | 23 +- pkg/appview/templates/pages/settings.html | 311 ++-------------- pkg/appview/templates/pages/terms.html | 4 +- pkg/appview/templates/pages/user.html | 20 +- pkg/appview/templates/partials/alert.html | 10 +- .../partials/attestation-details.html | 19 +- .../templates/partials/devices-table.html | 7 +- .../templates/partials/diff-content.html | 85 +++-- .../templates/partials/digest-content.html | 15 +- .../templates/partials/health-badge.html | 20 +- pkg/appview/templates/partials/hold_card.html | 7 +- .../templates/partials/hold_selector.html | 14 +- .../partials/image-advisor-results.html | 21 +- .../templates/partials/layers-section.html | 14 +- .../templates/partials/other_holds_table.html | 13 +- .../templates/partials/repo-tag-section.html | 30 +- pkg/appview/templates/partials/repo-tags.html | 13 +- .../templates/partials/sbom-details.html | 34 +- .../templates/partials/sbom-section.html | 13 +- .../templates/partials/search-results.html | 39 +- .../partials/settings-panel-advanced.html | 56 +++ .../partials/settings-panel-billing.html | 10 + .../partials/settings-panel-devices.html | 56 +++ .../partials/settings-panel-dispatch.html | 9 + .../partials/settings-panel-storage.html | 43 +++ .../partials/settings-panel-user.html | 55 +++ .../partials/settings-panel-webhooks.html | 11 + pkg/appview/templates/partials/state.html | 60 ++++ .../templates/partials/storage_stats.html | 21 +- .../templates/partials/subscription_info.html | 9 +- .../templates/partials/upgrade-banner.html | 8 +- .../templates/partials/vuln-badge.html | 9 +- .../templates/partials/vuln-details.html | 32 +- .../templates/partials/vulns-section.html | 17 +- .../templates/partials/webhooks_list.html | 3 +- pkg/appview/ui.go | 172 +++++++++ pkg/appview/ui_test.go | 1 - pkg/hold/admin/public/icons.svg | 6 +- themes/seamark/public/sitemap-static.xml | 5 + .../seamark/public/static/seamark_seagull.svg | 4 +- themes/seamark/templates/components/hero.html | 32 +- themes/seamark/theme.css | 6 +- 107 files changed, 2475 insertions(+), 1163 deletions(-) delete mode 100644 pkg/appview/templates/components/modal.html create mode 100644 pkg/appview/templates/partials/settings-panel-advanced.html create mode 100644 pkg/appview/templates/partials/settings-panel-billing.html create mode 100644 pkg/appview/templates/partials/settings-panel-devices.html create mode 100644 pkg/appview/templates/partials/settings-panel-dispatch.html create mode 100644 pkg/appview/templates/partials/settings-panel-storage.html create mode 100644 pkg/appview/templates/partials/settings-panel-user.html create mode 100644 pkg/appview/templates/partials/settings-panel-webhooks.html create mode 100644 pkg/appview/templates/partials/state.html diff --git a/.tangled/workflows/lint.yaml b/.tangled/workflows/lint.yaml index 24af4c9..4271cef 100644 --- a/.tangled/workflows/lint.yaml +++ b/.tangled/workflows/lint.yaml @@ -5,7 +5,7 @@ when: branch: ["main"] engine: kubernetes -image: golang:1.25-trixie +image: golang:1.26-trixie architecture: amd64 steps: diff --git a/.tangled/workflows/release-credential-helper.yml b/.tangled/workflows/release-credential-helper.yml index 456be2c..be2149d 100644 --- a/.tangled/workflows/release-credential-helper.yml +++ b/.tangled/workflows/release-credential-helper.yml @@ -12,7 +12,7 @@ when: tag: ["v*"] engine: kubernetes -image: golang:1.25-trixie +image: golang:1.26-trixie architecture: amd64 environment: diff --git a/.tangled/workflows/tests.yml b/.tangled/workflows/tests.yml index a17423f..18124af 100644 --- a/.tangled/workflows/tests.yml +++ b/.tangled/workflows/tests.yml @@ -5,7 +5,7 @@ when: branch: ["main"] engine: kubernetes -image: golang:1.25-trixie +image: golang:1.26-trixie architecture: amd64 steps: diff --git a/Dockerfile.appview b/Dockerfile.appview index abdf3b9..f437055 100644 --- a/Dockerfile.appview +++ b/Dockerfile.appview @@ -18,8 +18,13 @@ COPY . . RUN npm ci RUN go generate ./... +# Legal "Last updated" dates — pass from host (see Makefile docker-appview +# target). Empty falls back to the hardcoded default in legal.go. +ARG PRIVACY_DATE="" +ARG TERMS_DATE="" + RUN CGO_ENABLED=1 go build \ - -ldflags="-s -w -linkmode external -extldflags '-static'" \ + -ldflags="-s -w -linkmode external -extldflags '-static' -X 'atcr.io/pkg/appview/handlers.privacyLastUpdated=${PRIVACY_DATE}' -X 'atcr.io/pkg/appview/handlers.termsLastUpdated=${TERMS_DATE}'" \ -tags sqlite_omit_load_extension \ -trimpath \ -o atcr-appview ./cmd/appview diff --git a/Makefile b/Makefile index 86fd734..8ed691a 100644 --- a/Makefile +++ b/Makefile @@ -31,10 +31,18 @@ $(GENERATED_ASSETS): build: build-appview build-hold build-credential-helper ## Build all binaries +# Legal page "Last updated" dates come from the git commit date of the page +# templates. Empty values (e.g., Docker builds without .git) fall back to the +# hardcoded default in legal.go. +LEGAL_PKG := atcr.io/pkg/appview/handlers +PRIVACY_DATE := $(shell git log -1 --format=%cs -- pkg/appview/templates/pages/privacy.html 2>/dev/null) +TERMS_DATE := $(shell git log -1 --format=%cs -- pkg/appview/templates/pages/terms.html 2>/dev/null) +APPVIEW_LDFLAGS := -X '$(LEGAL_PKG).privacyLastUpdated=$(PRIVACY_DATE)' -X '$(LEGAL_PKG).termsLastUpdated=$(TERMS_DATE)' + build-appview: $(GENERATED_ASSETS) ## Build appview binary only @echo "→ Building appview..." @mkdir -p bin - go build -o bin/atcr-appview ./cmd/appview + go build -ldflags="$(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview build-hold: $(GENERATED_ASSETS) ## Build hold binary only @echo "→ Building hold..." @@ -69,7 +77,19 @@ test-verbose: ## Run tests with verbose output .PHONY: check-golangci-lint check-golangci-lint: - @which golangci-lint > /dev/null || (echo "→ Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) + @LINT_PKG=github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest; \ + CUR_GO=$$(go version | grep -oE 'go[0-9]+\.[0-9]+' | head -1 | sed 's/^go//'); \ + if ! command -v golangci-lint > /dev/null 2>&1; then \ + echo "→ Installing golangci-lint..."; \ + go install $$LINT_PKG; \ + else \ + LINT_GO=$$(golangci-lint --version 2>&1 | grep -oE 'built with go[0-9]+\.[0-9]+' | head -1 | sed 's/^built with go//'); \ + if [ -n "$$LINT_GO" ] && [ "$$LINT_GO" != "$$CUR_GO" ] && \ + [ "$$(printf '%s\n%s\n' $$LINT_GO $$CUR_GO | sort -V | head -1)" = "$$LINT_GO" ]; then \ + echo "→ golangci-lint built with go$$LINT_GO but project targets go$$CUR_GO — reinstalling..."; \ + go install $$LINT_PKG; \ + fi; \ + fi lint: check-golangci-lint ## Run golangci-lint @echo "→ Running golangci-lint..." @@ -97,7 +117,10 @@ docker: docker-appview docker-hold docker-scanner ## Build all Docker images docker-appview: ## Build appview Docker image @echo "→ Building appview Docker image..." - docker build -f Dockerfile.appview -t atcr.io/atcr.io/appview:latest . + docker build -f Dockerfile.appview \ + --build-arg PRIVACY_DATE=$(PRIVACY_DATE) \ + --build-arg TERMS_DATE=$(TERMS_DATE) \ + -t atcr.io/atcr.io/appview:latest . docker-hold: ## Build hold Docker image @echo "→ Building hold Docker image..." diff --git a/config-appview.example.yaml b/config-appview.example.yaml index 6391938..115219e 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -52,6 +52,8 @@ ui: libsql_auth_token: "" # How often to sync with remote libSQL server. Default: 60s. libsql_sync_interval: 1m0s + # Source code URL displayed in the footer "Source" link. Defaults to the upstream ATCR project. + source_url: https://tangled.org/evan.jarrett.net/at-container-registry # Health check and cache settings. health: # How long to cache hold health check results. @@ -74,7 +76,6 @@ jetstream: relay_endpoints: - https://relay1.us-east.bsky.network - https://relay1.us-west.bsky.network - - https://relay.waow.tech # JWT authentication settings. auth: # RSA private key for signing registry JWTs issued to Docker clients. @@ -100,9 +101,9 @@ billing: # ISO 4217 currency code (e.g. "usd"). currency: usd # Redirect URL after successful checkout. Use {base_url} placeholder. - success_url: '{base_url}/settings#billing' + success_url: '{base_url}/settings/billing' # Redirect URL after cancelled checkout. Use {base_url} placeholder. - cancel_url: '{base_url}/settings#billing' + cancel_url: '{base_url}/settings/billing' # Subscription tiers ordered by rank (lowest to highest). tiers: - # Tier name. Position in list determines rank (0-based). diff --git a/docs/BILLING_REFACTOR.md b/docs/BILLING_REFACTOR.md index 6a26754..6adaff0 100644 --- a/docs/BILLING_REFACTOR.md +++ b/docs/BILLING_REFACTOR.md @@ -206,8 +206,8 @@ server: billing: enabled: true currency: usd - success_url: "{base_url}/settings#storage" - cancel_url: "{base_url}/settings#storage" + success_url: "{base_url}/settings/billing" + cancel_url: "{base_url}/settings/billing" tiers: - name: "Free" # No stripe_price = free tier diff --git a/pkg/appview/config.go b/pkg/appview/config.go index d52bf28..93c5be1 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -83,6 +83,9 @@ type UIConfig struct { // How often to sync with the remote libSQL server. LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."` + + // Source code URL displayed in the footer "Source" link. + SourceURL string `yaml:"source_url" comment:"Source code URL displayed in the footer \"Source\" link. Defaults to the upstream ATCR project."` } // HealthConfig defines health check and cache settings @@ -162,6 +165,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("ui.libsql_sync_url", "") v.SetDefault("ui.libsql_auth_token", "") v.SetDefault("ui.libsql_sync_interval", "60s") + v.SetDefault("ui.source_url", "https://tangled.org/evan.jarrett.net/at-container-registry") // Health defaults v.SetDefault("health.cache_ttl", "15m") @@ -216,8 +220,8 @@ func ExampleYAML() ([]byte, error) { // Populate example billing tiers so operators see the structure cfg.Billing.Currency = "usd" - cfg.Billing.SuccessURL = "{base_url}/settings#billing" - cfg.Billing.CancelURL = "{base_url}/settings#billing" + cfg.Billing.SuccessURL = "{base_url}/settings/billing" + cfg.Billing.CancelURL = "{base_url}/settings/billing" cfg.Billing.OwnerBadge = true cfg.Billing.Tiers = []billing.BillingTierConfig{ {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1}, diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 8df3b8f..6db53d4 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -46,4 +46,5 @@ type BaseUIHandler struct { ClientName string // Full name: "AT Container Registry" ClientShortName string // Short name: "ATCR" AIAdvisorEnabled bool // True when Claude API key is configured + SourceURL string // Source code URL for the footer "Source" link } diff --git a/pkg/appview/handlers/common.go b/pkg/appview/handlers/common.go index 18f2759..4a11b38 100644 --- a/pkg/appview/handlers/common.go +++ b/pkg/appview/handlers/common.go @@ -18,6 +18,8 @@ type PageData struct { ClientShortName string // Brand name for templates (e.g., "ATCR") OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") AIAdvisorEnabled bool // True when AI Image Advisor is available + SourceURL string // Source code URL for the footer "Source" link + CurrentPath string // Request path (used for OAuth return_to) } // NewPageData creates a PageData struct with common fields populated from the request @@ -36,6 +38,8 @@ func NewPageData(r *http.Request, h *BaseUIHandler) PageData { ClientShortName: h.ClientShortName, OciClient: ociClient, AIAdvisorEnabled: h.AIAdvisorEnabled, + SourceURL: h.SourceURL, + CurrentPath: r.URL.RequestURI(), } } diff --git a/pkg/appview/handlers/device.go b/pkg/appview/handlers/device.go index e72e8b2..b4d372c 100644 --- a/pkg/appview/handlers/device.go +++ b/pkg/appview/handlers/device.go @@ -527,7 +527,7 @@ const deviceSuccessTemplate = `

✓ Device Authorized!

Device {{.DeviceName}} has been successfully authorized.

You can now close this window and return to your terminal.

-

View your authorized devices

+

View your authorized devices

diff --git a/pkg/appview/handlers/diff.go b/pkg/appview/handlers/diff.go index c2895b9..2721872 100644 --- a/pkg/appview/handlers/diff.go +++ b/pkg/appview/handlers/diff.go @@ -183,14 +183,18 @@ func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffE } func addToSevCount(s *vulnSummary, severity string) { - switch severity { - case "Critical": + // Normalize to canonical casing so "CRITICAL", "critical", "Crit" all land + // in the same bucket. Unknown severities count toward the total but don't + // bump any bucket — the template renders them as "Unknown" via the + // severityLabel helper. + switch strings.ToLower(strings.TrimSpace(severity)) { + case "critical", "crit", "c": s.Critical++ - case "High": + case "high", "h": s.High++ - case "Medium": + case "medium", "med", "m": s.Medium++ - case "Low": + case "low", "l": s.Low++ } s.Total++ @@ -387,17 +391,47 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) }() wg.Wait() - if fromData.err != nil || toData.err != nil { - RenderNotFound(w, r, &h.BaseUIHandler) - return + // Track per-side fetch failures so we render the page with an inline + // alert naming which tag failed, instead of a generic 404 that makes + // users guess whether they typoed a tag or hit a transient outage. + // fromData.manifest / toData.manifest is nil only when the re-fetch at + // the top of fetchManifest hit a DB error (the tag resolution earlier + // already ruled out typos). + fromFailed := fromData.err != nil || fromData.manifest == nil + toFailed := toData.err != nil || toData.manifest == nil + + // Fall back to the top-level manifest we already fetched so the page + // still has something to render for tag labels and metadata. + if fromFailed { + fromData.manifest = fromManifest + } + if toFailed { + toData.manifest = toManifest } // Compute diffs layerDiff := computeLayerDiff(fromData.layers, toData.layers) + // ScanStatus distinguishes why vuln data may be missing: "ok" when both + // sides returned clean scan results; "no-data" when a scan was never + // recorded; "hold-unreachable" when we couldn't reach the hold to ask. + // The template branches on these so users can tell "not scanned yet" + // from "hold offline" at a glance. + fromScanStatus := "ok" + toScanStatus := "ok" + if fromData.vulnData == nil { + fromScanStatus = "hold-unreachable" + } else if fromData.vulnData.Error != "" { + fromScanStatus = "no-data" + } + if toData.vulnData == nil { + toScanStatus = "hold-unreachable" + } else if toData.vulnData.Error != "" { + toScanStatus = "no-data" + } + var vulnDiff []VulnDiffEntry - hasVulnData := fromData.vulnData != nil && toData.vulnData != nil && - fromData.vulnData.Error == "" && toData.vulnData.Error == "" + hasVulnData := fromScanStatus == "ok" && toScanStatus == "ok" if hasVulnData { vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches) } @@ -448,6 +482,10 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) NewVulns []vulnMatch UnchangedVulns []vulnMatch HasVulnData bool + FromScanStatus string + ToScanStatus string + FromFailed bool + ToFailed bool IsMultiArch bool CommonPlatforms []db.PlatformInfo SelectedPlatform string @@ -468,6 +506,10 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) NewVulns: newVulns, UnchangedVulns: unchangedVulns, HasVulnData: hasVulnData, + FromScanStatus: fromScanStatus, + ToScanStatus: toScanStatus, + FromFailed: fromFailed, + ToFailed: toFailed, IsMultiArch: isMultiArch, CommonPlatforms: commonPlatforms, SelectedPlatform: selectedPlatform, diff --git a/pkg/appview/handlers/digest_content.go b/pkg/appview/handlers/digest_content.go index 9537c18..43bd0e2 100644 --- a/pkg/appview/handlers/digest_content.go +++ b/pkg/appview/handlers/digest_content.go @@ -4,6 +4,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdclient" @@ -21,99 +22,142 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) identifier := chi.URLParam(r, "handle") wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/") - // The wildcard is the repository name repository := wildcard - - // The platform digest comes from query param digest := r.URL.Query().Get("digest") if digest == "" || repository == "" { http.Error(w, "missing parameters", http.StatusBadRequest) return } - // Resolve identity did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } - // Fetch manifest details for the platform digest manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, digest) if err != nil { http.Error(w, "manifest not found", http.StatusNotFound) return } - // Fetch layers from DB - var layers []LayerDetail - var vulnData *vulnDetailsData - dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) if err != nil { slog.Warn("Failed to fetch layers", "error", err) } - // Resolve hold endpoint (follow successor if migrated) hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) + holdReachable := holdErr == nil - // Fetch OCI image config from hold for layer history - if holdErr == nil { - config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest) - if err == nil { - layers = buildLayerDetails(config.History, dbLayers) - } else { - slog.Warn("Failed to fetch image config", "error", err, - "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest) - layers = buildLayerDetails(nil, dbLayers) - } + // Parallelize the three hold fetches. They're independent and each + // takes a network round-trip; serial runs add up on slow links. + var ( + layers []LayerDetail + vulnData *vulnDetailsData + sbomData *sbomDetailsData + configFetchError bool + ) + + if holdReachable { + var wg sync.WaitGroup + wg.Add(3) + + go func() { + defer wg.Done() + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest) + if err == nil { + layers = buildLayerDetails(config.History, dbLayers) + } else { + slog.Warn("Failed to fetch image config", "error", err, + "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest) + layers = buildLayerDetails(nil, dbLayers) + configFetchError = true + } + }() + + go func() { + defer wg.Done() + vd := FetchVulnDetails(r.Context(), hold.DID, digest) + vulnData = &vd + }() + + go func() { + defer wg.Done() + sd := FetchSbomDetails(r.Context(), hold.DID, digest) + sbomData = &sd + }() + + wg.Wait() } else { layers = buildLayerDetails(nil, dbLayers) } - // Fetch vulnerability and SBOM details - var sbomData *sbomDetailsData - if holdErr == nil { - vd := FetchVulnDetails(r.Context(), hold.DID, digest) - vulnData = &vd - sd := FetchSbomDetails(r.Context(), hold.DID, digest) - sbomData = &sd + // VulnReason / SbomReason let the template branch distinctly on why + // data is missing instead of collapsing three causes into a generic + // "not available" message. + // ok — data is present + // hold-unreachable — we couldn't reach the hold + // not-scanned — hold is up but no scan record exists + // fetch-failed — scan record fetch failed on the hold + vulnReason := "ok" + if !holdReachable { + vulnReason = "hold-unreachable" + } else if vulnData == nil || vulnData.Error == "never-scanned" { + vulnReason = "not-scanned" + } else if vulnData.Error != "" { + vulnReason = "fetch-failed" + } + + sbomReason := "ok" + if !holdReachable { + sbomReason = "hold-unreachable" + } else if sbomData == nil || sbomData.Error == "never-scanned" { + sbomReason = "not-scanned" + } else if sbomData.Error != "" { + sbomReason = "fetch-failed" } data := struct { - Layers []LayerDetail - VulnData *vulnDetailsData - SbomData *sbomDetailsData + Layers []LayerDetail + VulnData *vulnDetailsData + SbomData *sbomDetailsData + HoldReachable bool + ConfigFetchError bool + VulnReason string + SbomReason string }{ - Layers: layers, - VulnData: vulnData, - SbomData: sbomData, + Layers: layers, + VulnData: vulnData, + SbomData: sbomData, + HoldReachable: holdReachable, + ConfigFetchError: configFetchError, + VulnReason: vulnReason, + SbomReason: sbomReason, } w.Header().Set("Content-Type", "text/html") - // Support rendering individual sections for repo page tabs section := r.URL.Query().Get("section") switch section { case "layers": if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil { slog.Warn("Failed to render layers section", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render layers", err) } case "vulns": if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil { slog.Warn("Failed to render vulns section", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render vulnerabilities", err) } case "sbom": if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil { slog.Warn("Failed to render sbom section", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render SBOM", err) } default: if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil { slog.Warn("Failed to render digest content", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render content", err) } } } diff --git a/pkg/appview/handlers/errors.go b/pkg/appview/handlers/errors.go index d345a6e..124f0a6 100644 --- a/pkg/appview/handlers/errors.go +++ b/pkg/appview/handlers/errors.go @@ -1,6 +1,8 @@ package handlers import ( + "encoding/json" + "log/slog" "net/http" ) @@ -36,3 +38,33 @@ func RenderNotFound(w http.ResponseWriter, r *http.Request, h *BaseUIHandler) { http.Error(w, "Page not found", http.StatusNotFound) } } + +// RenderHTMXError sends an error response suitable for htmx. For htmx requests +// it sets an HX-Trigger header so the client fires a toast event; the JS +// fallback in app.js will show a generic toast even without the header. +// For non-htmx requests it falls back to http.Error. serverErr is logged but +// never exposed to the user — pass userMsg for anything screen-readable. +func RenderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) { + if serverErr != nil { + slog.Error("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/appview/handlers/home.go b/pkg/appview/handlers/home.go index 61a71db..ffcaddb 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -4,7 +4,7 @@ package handlers import ( - "log" + "log/slog" "net/http" "atcr.io/pkg/appview/db" @@ -17,25 +17,29 @@ type HomeHandler struct { } func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Get current user DID (empty string if not logged in) var currentUserDID string if user := middleware.GetUser(r); user != nil { currentUserDID = user.DID } - // Fetch featured repositories (top 6 by score - carousel cycles through them) + // Track whether either card query failed so the page can surface a + // distinct error banner instead of the "no repos yet" empty state. + // Partial failures still render whatever did succeed. + var queryError bool + featuredCards, err := db.GetRepoCards(h.ReadOnlyDB, 6, currentUserDID, db.SortByScore) if err != nil { - log.Printf("Error fetching featured repos: %v", err) + slog.Error("home: fetch featured repos", "err", err) featuredCards = []db.RepoCardData{} + queryError = true } db.SetRegistryURL(featuredCards, h.RegistryURL) - // Fetch recently updated repositories (top 18 by last push - 6 rows at 3-col lg) recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate) if err != nil { - log.Printf("Error fetching recent repos: %v", err) + slog.Error("home: fetch recent repos", "err", err) recentCards = []db.RepoCardData{} + queryError = true } db.SetRegistryURL(recentCards, h.RegistryURL) @@ -48,6 +52,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Meta *PageMeta FeaturedRepos []db.RepoCardData RecentRepos []db.RepoCardData + HasError bool }{ PageData: pageData, Meta: NewPageMeta( @@ -63,6 +68,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ), FeaturedRepos: featuredCards, RecentRepos: recentCards, + HasError: queryError, } if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil { diff --git a/pkg/appview/handlers/image_advisor.go b/pkg/appview/handlers/image_advisor.go index 8238d55..2462db3 100644 --- a/pkg/appview/handlers/image_advisor.go +++ b/pkg/appview/handlers/image_advisor.go @@ -40,8 +40,16 @@ type advisorSuggestion struct { type imageAdvisorData struct { Suggestions []advisorSuggestion Error string + // Model is shown in the results footer so users can attribute the + // suggestions to a specific model without us hardcoding it in the template. + Model string } +// advisorModel is the Claude model used for image suggestions. Kept in one +// place so the API call and the template footer stay in sync. +const advisorModel = "claude-haiku-4-5-20251001" +const advisorModelDisplay = "Claude Haiku 4.5" + // OCI config types for full image config parsing type advisorOCIConfig struct { Architecture string `json:"architecture"` @@ -168,7 +176,7 @@ func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) suggestions, err := parseAdvisorResponse(cachedJSON) if err == nil { slog.Debug("Serving cached advisor suggestions", "digest", digest) - h.renderResults(w, imageAdvisorData{Suggestions: suggestions}) + h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay}) return } slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest) @@ -217,11 +225,13 @@ func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) var promptBuf strings.Builder generateAdvisorPrompt(&promptBuf, report) - // Call Claude API + // Call Claude API. The raw error often contains upstream HTTP body text + // which we must not surface to the user (potential secrets/PII). Log the + // detail; show a stable, sanitized message. responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String()) if err != nil { slog.Warn("Claude API call failed", "error", err) - h.renderResults(w, imageAdvisorData{Error: "AI service request failed: " + err.Error()}) + h.renderResults(w, imageAdvisorData{Error: "The AI service couldn't generate suggestions right now. Please try again in a minute."}) return } @@ -229,7 +239,7 @@ func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) suggestions, err := parseAdvisorResponse(responseText) if err != nil { slog.Warn("Failed to parse advisor response", "error", err, "response", responseText) - h.renderResults(w, imageAdvisorData{Error: "Failed to parse AI response"}) + h.renderResults(w, imageAdvisorData{Error: "We got a response from the AI service but couldn't read it. Please try again."}) return } @@ -238,7 +248,7 @@ func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) slog.Warn("Failed to cache advisor suggestions", "error", err) } - h.renderResults(w, imageAdvisorData{Suggestions: suggestions}) + h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay}) } func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) { @@ -583,7 +593,7 @@ func generateAdvisorPrompt(w io.Writer, r *advisorReportData) { // callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON. func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) { reqBody := map[string]any{ - "model": "claude-haiku-4-5-20251001", + "model": advisorModel, "max_tokens": 2048, "system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).", "tools": []map[string]any{{ diff --git a/pkg/appview/handlers/legal.go b/pkg/appview/handlers/legal.go index 17fc2b8..96ff633 100644 --- a/pkg/appview/handlers/legal.go +++ b/pkg/appview/handlers/legal.go @@ -2,14 +2,49 @@ package handlers import ( "net/http" + "time" ) -// LegalPageData contains data for legal pages (terms, privacy) +// LegalPageData contains data for legal pages (terms, privacy). type LegalPageData struct { PageData Meta *PageMeta CompanyName string Jurisdiction string + LastUpdated string +} + +// legalDefaults applies sensible fallbacks for operators who haven't set +// CompanyName/Jurisdiction in config. +func legalDefaults(company, jurisdiction string) (string, string) { + if company == "" { + company = "the Service" + } + if jurisdiction == "" { + jurisdiction = "United States" + } + return company, jurisdiction +} + +// Stamped at build time from the git commit date of the corresponding page +// template via -ldflags -X (see Makefile). Empty falls back to legalFallbackDate +// for bare `go build` / builds without a .git directory. +var ( + privacyLastUpdated string + termsLastUpdated string +) + +const legalFallbackDate = "April 2026" + +func formatLegalDate(raw string) string { + if raw == "" { + return legalFallbackDate + } + t, err := time.Parse("2006-01-02", raw) + if err != nil { + return raw + } + return t.Format("January 2, 2006") } // PrivacyPolicyHandler handles the /privacy page @@ -24,11 +59,13 @@ func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) ).WithCanonical("https://" + h.SiteURL + "/privacy"). WithSiteName(h.ClientShortName) + company, jurisdiction := legalDefaults(h.CompanyName, h.Jurisdiction) data := LegalPageData{ PageData: NewPageData(r, &h.BaseUIHandler), Meta: meta, - CompanyName: h.CompanyName, - Jurisdiction: h.Jurisdiction, + CompanyName: company, + Jurisdiction: jurisdiction, + LastUpdated: formatLegalDate(privacyLastUpdated), } if err := h.Templates.ExecuteTemplate(w, "privacy", data); err != nil { @@ -49,11 +86,13 @@ func (h *TermsOfServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request ).WithCanonical("https://" + h.SiteURL + "/terms"). WithSiteName(h.ClientShortName) + company, jurisdiction := legalDefaults(h.CompanyName, h.Jurisdiction) data := LegalPageData{ PageData: NewPageData(r, &h.BaseUIHandler), Meta: meta, - CompanyName: h.CompanyName, - Jurisdiction: h.Jurisdiction, + CompanyName: company, + Jurisdiction: jurisdiction, + LastUpdated: formatLegalDate(termsLastUpdated), } if err := h.Templates.ExecuteTemplate(w, "terms", data); err != nil { diff --git a/pkg/appview/handlers/manifest_health.go b/pkg/appview/handlers/manifest_health.go index 575d4c7..b27a94e 100644 --- a/pkg/appview/handlers/manifest_health.go +++ b/pkg/appview/handlers/manifest_health.go @@ -2,12 +2,45 @@ package handlers import ( "context" + "errors" "log/slog" + "net" "net/http" "net/url" + "strings" "time" ) +// classifyHealthError maps a CheckHealth error into a short reason code that +// the template turns into a distinct tooltip. Prevents the badge from +// collapsing every failure mode into a generic "Offline". +// +// Returns one of: "dns", "tls", "refused", "timeout", "http", "unknown" +// (empty string when err is nil). +func classifyHealthError(err error) string { + if err == nil { + return "" + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return "dns" + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "x509") || strings.Contains(msg, "tls:") || strings.Contains(msg, "certificate") { + return "tls" + } + if strings.Contains(msg, "connection refused") { + return "refused" + } + if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") { + return "timeout" + } + if strings.Contains(msg, "status") || strings.Contains(msg, "http") { + return "http" + } + return "unknown" +} + // ManifestHealthHandler handles HTMX polling for manifest health status type ManifestHealthHandler struct { BaseUIHandler @@ -32,7 +65,7 @@ func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request cached := h.HealthChecker.GetCachedStatus(endpoint) if cached != nil { // Cache hit - return final status - h.renderBadge(w, endpoint, cached.Reachable, false) + h.renderBadge(w, endpoint, cached.Reachable, false, "") return } @@ -43,30 +76,31 @@ func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint) // Check for HTTP errors first (connection refused, network unreachable, etc.) - // This ensures we catch real failures even when timing aligns with context timeout + // This ensures we catch real failures even when timing aligns with context timeout. if err != nil { - // Error - mark as unreachable - h.renderBadge(w, endpoint, false, false) + h.renderBadge(w, endpoint, false, false, classifyHealthError(err)) } else if ctx.Err() == context.DeadlineExceeded { - // Context timed out but no HTTP error yet - still pending - h.renderBadge(w, endpoint, false, true) + h.renderBadge(w, endpoint, false, true, "") } else { - // Success - h.renderBadge(w, endpoint, reachable, false) + h.renderBadge(w, endpoint, reachable, false, "") } } -// renderBadge renders the appropriate badge HTML snippet -func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool) { +// renderBadge renders the appropriate badge HTML snippet. Reason is one of the +// classifyHealthError codes ("dns", "tls", "refused", "timeout", "http", +// "unknown") or empty for success / pending states. +func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool, reason string) { w.Header().Set("Content-Type", "text/html") data := struct { Pending bool Reachable bool + Reason string RetryURL string }{ Pending: pending, Reachable: reachable, + Reason: reason, RetryURL: url.QueryEscape(endpoint), } diff --git a/pkg/appview/handlers/meta.go b/pkg/appview/handlers/meta.go index 3b1a24a..6daeaae 100644 --- a/pkg/appview/handlers/meta.go +++ b/pkg/appview/handlers/meta.go @@ -3,18 +3,23 @@ package handlers // PageMeta holds all metadata for a page's section. // Use the builder methods to construct it with a fluent API. type PageMeta struct { - Title string // Page title (required) - Description string // Meta description (required) + Title string // Page title (required; empty falls back to SiteName in template) + Description string // Meta description (required; empty omits the tag entirely) Canonical string // Canonical URL (optional) Robots string // Robots directive, e.g. "noindex" (optional, defaults to "index, follow") OGType string // OpenGraph type, defaults to "website" OGImage string // OpenGraph image URL (optional) + OGImageAlt string // OpenGraph image alt text — improves social-share a11y + OGLocale string // OpenGraph locale (e.g. "en_US"); blank falls back in template TwitterCard string // Twitter card type, defaults to "summary_large_image" - SiteName string // Site name for og:site_name (optional, defaults to "ATCR") + SiteName string // Site name for og:site_name (falls back to "ATCR" in template) JSONLD []any // JSON-LD structured data objects (optional) } // NewPageMeta creates a new PageMeta with required fields and sensible defaults. +// Callers should not pass empty title/description — the template falls back to +// the SiteName for missing title and omits missing description, but those are +// last-resort defenses. func NewPageMeta(title, description string) *PageMeta { return &PageMeta{ Title: title, @@ -36,6 +41,19 @@ func (m *PageMeta) WithOGImage(url string) *PageMeta { return m } +// WithOGImageAlt sets the alt text for the OpenGraph image. Strongly recommended +// when OGImage is set — screen readers on social platforms read this out. +func (m *PageMeta) WithOGImageAlt(alt string) *PageMeta { + m.OGImageAlt = alt + return m +} + +// WithOGLocale overrides the default "en_US" locale. +func (m *PageMeta) WithOGLocale(locale string) *PageMeta { + m.OGLocale = locale + return m +} + // WithOGType sets the OpenGraph type (e.g., "website", "profile", "article"). func (m *PageMeta) WithOGType(ogType string) *PageMeta { m.OGType = ogType @@ -54,7 +72,9 @@ func (m *PageMeta) WithJSONLD(data ...any) *PageMeta { return m } -// WithSiteName sets the site name for og:site_name. +// WithSiteName sets the site name for og:site_name. Pass the caller's +// ClientShortName — forgetting this on a branded deployment (e.g. Seamark) +// leaks "ATCR" into social previews. func (m *PageMeta) WithSiteName(name string) *PageMeta { m.SiteName = name return m diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 03179c2..9229a69 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -182,8 +182,11 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request repo.Version = metadata["org.opencontainers.image.version"] } - // Fetch stats + // Fetch stats. Track availability separately so the template can render + // "—" or hide the stats row instead of showing zeros that masquerade as + // real counts. stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository) + statsAvailable := err == nil if err != nil { slog.Warn("Failed to fetch repository stats", "error", err) stats = &db.RepositoryStats{StarCount: 0} @@ -210,9 +213,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request isOwner = (user.DID == owner.DID) } - // Fetch README content from repo page record or annotations + // Fetch README content from repo page record or annotations. + // ReadmeFetchFailed distinguishes "owner never provided a README" (show + // CTA to add one) from "we tried to fetch the configured README and it + // failed" (show retry CTA instead). var readmeHTML template.HTML var rawDescription string + var readmeFetchFailed bool repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository) if err == nil && repoPage != nil { @@ -238,15 +245,16 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } } if readmeURL != "" { - // Fetch raw markdown for editor pre-fill, then render rawBytes, fetchErr := h.ReadmeFetcher.FetchRaw(r.Context(), readmeURL) if fetchErr != nil { slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", fetchErr) + readmeFetchFailed = true } else { rawDescription = string(rawBytes) html, renderErr := h.ReadmeFetcher.RenderMarkdown(rawBytes) if renderErr != nil { slog.Debug("Failed to render fetched README", "url", readmeURL, "error", renderErr) + readmeFetchFailed = true } else { readmeHTML = template.HTML(html) } @@ -299,34 +307,38 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request data := struct { PageData - Meta *PageMeta - Owner *db.User - Repository *db.Repository - AllTags []string - SelectedTag *SelectedTagData - Stats *db.RepositoryStats - TagCount int - IsStarred bool - IsOwner bool - ReadmeHTML template.HTML - RawDescription string - ArtifactType string - NonDefaultHolds []string + Meta *PageMeta + Owner *db.User + Repository *db.Repository + AllTags []string + SelectedTag *SelectedTagData + Stats *db.RepositoryStats + StatsAvailable bool + TagCount int + IsStarred bool + IsOwner bool + ReadmeHTML template.HTML + ReadmeFetchFailed bool + RawDescription string + ArtifactType string + NonDefaultHolds []string }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Meta: meta, - Owner: owner, - Repository: repo, - AllTags: allTags, - SelectedTag: selectedTag, - Stats: stats, - TagCount: tagCount, - IsStarred: isStarred, - IsOwner: isOwner, - ReadmeHTML: readmeHTML, - RawDescription: rawDescription, - ArtifactType: artifactType, - NonDefaultHolds: nonDefaultHolds, + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Owner: owner, + Repository: repo, + AllTags: allTags, + SelectedTag: selectedTag, + Stats: stats, + StatsAvailable: statsAvailable, + TagCount: tagCount, + IsStarred: isStarred, + IsOwner: isOwner, + ReadmeHTML: readmeHTML, + ReadmeFetchFailed: readmeFetchFailed, + RawDescription: rawDescription, + ArtifactType: artifactType, + NonDefaultHolds: nonDefaultHolds, } // If the owner has disabled AI advisor in their profile, hide the button diff --git a/pkg/appview/handlers/scan_result.go b/pkg/appview/handlers/scan_result.go index fb9653a..7da82a5 100644 --- a/pkg/appview/handlers/scan_result.go +++ b/pkg/appview/handlers/scan_result.go @@ -25,6 +25,14 @@ type ScanResultHandler struct { } // vulnBadgeData is the template data for the vuln-badge partial. +// The badge renders one of four states, in priority order: +// 1. Error — we couldn't reach the hold at all (network/5xx) +// 2. NotScanned — hold reachable, no scan record for this digest (404) +// 3. ScanFailed — scan record exists but the scanner didn't produce an SBOM +// 4. Found — scan succeeded; render tier counts (or "Clean" when zero) +// +// These states must stay distinct so users can tell "hold is down" from +// "this hasn't been scanned yet" from "scanner errored on this image". type vulnBadgeData struct { Critical int64 High int64 @@ -32,9 +40,10 @@ type vulnBadgeData struct { Low int64 Total int64 ScannedAt string - Found bool // true if scan record exists - Error bool // true if hold unreachable or error - ScanFailed bool // true if scan record exists but scan failed (no blobs) + Found bool // true if scan record exists and succeeded + Error bool // true if hold unreachable (network/5xx) + NotScanned bool // true if hold is up but no scan record (404) + ScanFailed bool // true if scan record exists but scan failed (no SBOM) Digest string // for the detail modal link HoldEndpoint string // for the detail modal link } @@ -87,8 +96,9 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - // No scan record — scanning disabled or not yet scanned. Render nothing. - h.renderBadge(w, vulnBadgeData{Error: true}) + // Hold is reachable but has no scan record — not yet scanned, or + // the image was pushed before scanning was enabled. + h.renderBadge(w, vulnBadgeData{NotScanned: true}) return } @@ -160,6 +170,9 @@ func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest strin } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return vulnBadgeData{NotScanned: true} + } if resp.StatusCode != http.StatusOK { return vulnBadgeData{Error: true} } @@ -214,8 +227,14 @@ func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques if err != nil { slog.Debug("Failed to resolve hold for batch scan", "holdEndpoint", holdEndpoint, "error", err) w.Header().Set("Content-Type", "text/html") + // Emit "not scanned" badge for every digest so the placeholder resolves visibly. + var buf bytes.Buffer + if err := h.Templates.ExecuteTemplate(&buf, "vuln-badge", vulnBadgeData{Error: true}); err != nil { + slog.Warn("Failed to render vuln-badge placeholder", "error", err) + } for _, d := range digests { - fmt.Fprintf(w, ``, template.HTMLEscapeString(d)) + fmt.Fprintf(w, `%s`, + template.HTMLEscapeString(d), buf.String()) } return } diff --git a/pkg/appview/handlers/scan_result_test.go b/pkg/appview/handlers/scan_result_test.go index 11b8e0a..f728d0c 100644 --- a/pkg/appview/handlers/scan_result_test.go +++ b/pkg/appview/handlers/scan_result_test.go @@ -165,9 +165,10 @@ func TestScanResult_NotFound(t *testing.T) { body := strings.TrimSpace(rr.Body.String()) - // 404 = no scan record. Should render NOTHING — not "Scan pending". - if body != "" { - t.Errorf("Expected empty body for 404, got: %q", body) + // 404 = no scan record yet. Renders a visible "Not scanned" placeholder + // so the htmx target resolves instead of staying empty forever. + if !strings.Contains(body, "Not scanned") { + t.Errorf("Expected 'Not scanned' placeholder for 404, got: %q", body) } } @@ -189,8 +190,9 @@ func TestScanResult_HoldError(t *testing.T) { body := strings.TrimSpace(rr.Body.String()) - if body != "" { - t.Errorf("Expected empty body for hold error, got: %q", body) + // Hold reachable but returned 5xx — distinct from "not scanned". + if !strings.Contains(body, "Hold offline") { + t.Errorf("Expected 'Hold offline' badge for hold error, got: %q", body) } } @@ -207,8 +209,9 @@ func TestScanResult_HoldUnreachable(t *testing.T) { body := strings.TrimSpace(rr.Body.String()) - if body != "" { - t.Errorf("Expected empty body for unreachable hold, got: %q", body) + // Network-unreachable hold — also distinct from "not scanned". + if !strings.Contains(body, "Hold offline") { + t.Errorf("Expected 'Hold offline' badge for unreachable hold, got: %q", body) } } diff --git a/pkg/appview/handlers/search.go b/pkg/appview/handlers/search.go index 4b8ad89..93f3239 100644 --- a/pkg/appview/handlers/search.go +++ b/pkg/appview/handlers/search.go @@ -9,15 +9,66 @@ import ( "atcr.io/pkg/appview/middleware" ) -// SearchHandler handles the search page +// searchPageSize is the per-page result count for both initial render and +// "Load More" pagination. Kept consistent so noscript and htmx paths agree. +const searchPageSize = 50 + +// searchResults holds the data shared by the full-page and partial renders. +// Pulled out so SearchHandler can server-render the first page inline and +// SearchResultsHandler can emit just the partial for htmx Load More. +type searchResults struct { + PageData + Repositories []db.RepoCardData + SearchQuery string + HasMore bool + NextOffset int + // HasError is true when the DB query failed. Template branches to the + // shared error state rather than the empty-results copy. + HasError bool +} + +func (h *BaseUIHandler) runSearch(r *http.Request, query string, offset int) (searchResults, error) { + pageData := NewPageData(r, h) + + var currentUserDID string + if user := middleware.GetUser(r); user != nil { + currentUserDID = user.DID + } + + repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, searchPageSize, offset, currentUserDID) + if err != nil { + return searchResults{ + PageData: pageData, + SearchQuery: query, + HasError: true, + }, err + } + + db.SetRegistryURL(repos, h.RegistryURL) + db.SetOciClient(repos, pageData.OciClient) + + return searchResults{ + PageData: pageData, + Repositories: repos, + SearchQuery: query, + HasMore: offset+searchPageSize < total, + NextOffset: offset + searchPageSize, + }, nil +} + +// SearchHandler handles the search page. When a query is provided, it runs +// the search server-side so the page works without JavaScript; htmx only +// takes over for the "Load More" pagination link. type SearchHandler struct { BaseUIHandler } func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") + query := strings.TrimSpace(r.URL.Query().Get("q")) + if len(query) > 200 { + query = query[:200] + } - // Build page meta title := "Search - " + h.ClientShortName description := "Search for container images on " + h.ClientShortName + ", the decentralized container registry" canonical := "https://" + h.SiteURL + "/search" @@ -29,14 +80,29 @@ func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { meta := NewPageMeta(title, description).WithCanonical(canonical).WithSiteName(h.ClientShortName) + var results searchResults + if query != "" { + var err error + results, err = h.runSearch(r, query, 0) + if err != nil { + // Don't 500 the whole page — render it with the error-state + // partial so the search form stays usable. + results.HasError = true + } + } else { + results.PageData = NewPageData(r, &h.BaseUIHandler) + } + data := struct { PageData Meta *PageMeta SearchQuery string + Results searchResults }{ - PageData: NewPageData(r, &h.BaseUIHandler), + PageData: results.PageData, Meta: meta, SearchQuery: query, + Results: results, } if err := h.Templates.ExecuteTemplate(w, "search", data); err != nil { @@ -45,82 +111,49 @@ func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// SearchResultsHandler handles the HTMX request for search results +// SearchResultsHandler serves the search-results partial for htmx Load More +// pagination. Returns just the grid fragment, not a full page. type SearchResultsHandler struct { BaseUIHandler } func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") - - // Validate and sanitize input - query = strings.TrimSpace(query) - if query == "" { - // Return empty results if no query - data := struct { - PageData - Repositories []db.RepoCardData - SearchQuery string - HasMore bool - NextOffset int - }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Repositories: []db.RepoCardData{}, - SearchQuery: "", - HasMore: false, - } - - if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - - // Limit query length to prevent abuse + query := strings.TrimSpace(r.URL.Query().Get("q")) if len(query) > 200 { query = query[:200] } - limit := 50 - offset := 0 + if query == "" { + empty := searchResults{ + PageData: NewPageData(r, &h.BaseUIHandler), + SearchQuery: "", + } + if err := h.Templates.ExecuteTemplate(w, "search-results", empty); err != nil { + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render results", err) + } + return + } + offset := 0 if o := r.URL.Query().Get("offset"); o != "" { offset, _ = strconv.Atoi(o) } - // Get current user DID (empty string if not logged in) - var currentUserDID string - if user := middleware.GetUser(r); user != nil { - currentUserDID = user.DID - } - - repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, limit, offset, currentUserDID) + results, err := h.runSearch(r, query, offset) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + RenderHTMXError(w, r, http.StatusInternalServerError, "Search is temporarily unavailable", err) return } - // Set registry URL and OCI client on all cards - db.SetRegistryURL(repos, h.RegistryURL) - pageData := NewPageData(r, &h.BaseUIHandler) - db.SetOciClient(repos, pageData.OciClient) - - data := struct { - PageData - Repositories []db.RepoCardData - SearchQuery string - HasMore bool - NextOffset int - }{ - PageData: pageData, - Repositories: repos, - SearchQuery: query, - HasMore: offset+limit < total, - NextOffset: offset + limit, + // Load More requests (offset > 0) render just the new cards plus a + // replacement Load More button via card-grid-append. Cards are OOB-swapped + // into the existing grid so the old grid, cards, and scroll position stay + // put. The primary outerHTML swap replaces the old Load More wrapper. + template := "search-results" + if offset > 0 { + template = "card-grid-append-search" } - - if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + if err := h.Templates.ExecuteTemplate(w, template, results); err != nil { + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render results", err) } } diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 9f5fc6f..75199f6 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -31,146 +31,203 @@ type HoldDisplay struct { IsActive bool `json:"isActive"` } -// SettingsHandler handles the settings page +// SettingsHandler handles the settings page — dispatches per-tab. type SettingsHandler struct { BaseUIHandler } +// settingsTab describes a tab entry rendered in the tablist. +type settingsTab struct { + Slug string + Label string + Icon string +} + +func settingsTabs() []settingsTab { + return []settingsTab{ + {Slug: "user", Label: "User", Icon: "user"}, + {Slug: "billing", Label: "Billing", Icon: "credit-card"}, + {Slug: "storage", Label: "Storage", Icon: "hard-drive"}, + {Slug: "devices", Label: "Devices", Icon: "terminal"}, + {Slug: "webhooks", Label: "Webhooks", Icon: "webhook"}, + {Slug: "advanced", Label: "Advanced", Icon: "shield-check"}, + } +} + +var validSettingsTabs = map[string]bool{ + "user": true, "storage": true, "billing": true, + "devices": true, "webhooks": true, "advanced": true, +} + +// settingsProfile is the sidebar identity info shared across all tabs. +type settingsProfile struct { + Handle string + DID string + PDSEndpoint string + DefaultHold string + AutoRemoveUntagged bool + OciClient string + AIAdvisorEnabled bool + HasAIAdvisorAccess bool +} + +// settingsPageData is the struct passed to the settings shell + panel templates. +// MemberHolds are holds where the user is already owner/crew; EligibleHolds +// are ones they can opt-in to join. Splitting them upstream keeps the +// hold_selector template from doing filter-the-same-list-twice gymnastics. +type settingsPageData struct { + PageData + Meta *PageMeta + ActiveTab string + Tabs []settingsTab + Profile settingsProfile + ActiveHold *HoldDisplay + OtherHolds []HoldDisplay + MemberHolds []HoldDisplay + EligibleHolds []HoldDisplay + WebhooksData webhooksTemplateData + Subscription SubscriptionDisplay +} + +// ServeHTTP redirects /settings to /settings/user. func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) - return - } + http.Redirect(w, r, "/settings/user", http.StatusFound) +} - // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) +// ServeTab returns an http.Handler for a specific settings tab. +// If HX-Request is set, only the panel fragment is rendered. +func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !validSettingsTabs[tab] { + http.NotFound(w, r) + return + } - // Fetch sailor profile - profile, err := storage.GetProfile(r.Context(), client) - if err != nil { - // Error fetching profile - log out user - slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err) - http.Redirect(w, r, "/auth/logout", http.StatusFound) - return - } + user := middleware.GetUser(r) + if user == nil { + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings/"+tab, http.StatusFound) + return + } - if profile == nil { - // Profile doesn't exist yet (404) - user needs to log out and back in to create it - slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID) - http.Redirect(w, r, "/auth/logout", http.StatusFound) - return - } + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold) - - // Get available holds - var activeHold *HoldDisplay - var otherHolds, allHolds []HoldDisplay - - if h.DB != nil { - availableHolds, err := db.GetAvailableHolds(h.DB, user.DID) + profile, err := storage.GetProfile(r.Context(), client) if err != nil { - slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err) - } else { - for _, hold := range availableHolds { - display := HoldDisplay{ - DID: hold.HoldDID, - DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID), - Region: hold.Region, - Membership: hold.Membership, - IsActive: hold.HoldDID == profile.DefaultHold, - } + slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err) + http.Redirect(w, r, "/auth/logout", http.StatusFound) + return + } + if profile == nil { + slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID) + http.Redirect(w, r, "/auth/logout", http.StatusFound) + return + } - // Parse permissions JSON if present - if hold.Permissions != "" { - if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil { - slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err) - } - } + meta := NewPageMeta( + "Settings - "+h.ClientShortName, + "Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences", + ).WithRobots("noindex"). + WithSiteName(h.ClientShortName) - // Check health status (uses cache if available, otherwise pings on-demand) - if h.HealthChecker != nil { - if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil { - if status.Reachable { - display.Status = "online" - } else { - display.Status = "offline" - } - } - } + data := settingsPageData{ + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + ActiveTab: tab, + Tabs: settingsTabs(), + Profile: settingsProfile{ + Handle: user.Handle, + DID: user.DID, + PDSEndpoint: user.PDSEndpoint, + DefaultHold: profile.DefaultHold, + AutoRemoveUntagged: profile.AutoRemoveUntagged, + OciClient: profile.OciClient, + AIAdvisorEnabled: profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled, + }, + } + if h.BillingManager != nil { + data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID) + } - // All holds go in dropdown list - allHolds = append(allHolds, display) + // Per-tab data fetch. + switch tab { + case "storage": + data.ActiveHold, data.OtherHolds, data.MemberHolds, data.EligibleHolds = h.buildHoldsData(r.Context(), user.DID, profile.DefaultHold) + case "billing": + data.Subscription = h.buildSubscriptionDisplay(user.DID) + case "webhooks": + data.WebhooksData = h.buildWebhooksData(user.DID) + } - // Separate active from other member holds (skip eligible) - if hold.Membership != "eligible" { - if display.IsActive { - holdCopy := display - activeHold = &holdCopy - } else { - otherHolds = append(otherHolds, display) - } + // htmx partial: render just the panel. + tmplName := "settings" + if r.Header.Get("HX-Request") == "true" { + tmplName = "settings-panel" + } + if err := h.Templates.ExecuteTemplate(w, tmplName, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +// buildHoldsData resolves the current user's holds for the storage tab. +// Returns: the currently-active hold (if any), non-active member holds, the +// full member-hold list (including active, for selector rendering), and +// eligible holds (the user could join but isn't yet a member of). +func (h *SettingsHandler) buildHoldsData(ctx context.Context, userDID, defaultHold string) (*HoldDisplay, []HoldDisplay, []HoldDisplay, []HoldDisplay) { + if h.DB == nil { + return nil, nil, nil, nil + } + + availableHolds, err := db.GetAvailableHolds(h.DB, userDID) + if err != nil { + slog.Warn("Failed to get available holds", "component", "settings", "did", userDID, "error", err) + return nil, nil, nil, nil + } + + var activeHold *HoldDisplay + var otherHolds, memberHolds, eligibleHolds []HoldDisplay + + for _, hold := range availableHolds { + display := HoldDisplay{ + DID: hold.HoldDID, + DisplayName: resolveHoldDisplayName(ctx, &h.BaseUIHandler, hold.HoldDID), + Region: hold.Region, + Membership: hold.Membership, + IsActive: hold.HoldDID == defaultHold, + } + + if hold.Permissions != "" { + if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil { + slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", userDID, "hold_did", hold.HoldDID, "error", err) + } + } + + if h.HealthChecker != nil { + if status := h.HealthChecker.GetStatus(ctx, hold.HoldDID); status != nil { + if status.Reachable { + display.Status = "online" + } else { + display.Status = "offline" } } } - } - // Fetch webhooks (local DB read) - webhooksData := h.buildWebhooksData(user.DID) + if hold.Membership == "eligible" { + eligibleHolds = append(eligibleHolds, display) + continue + } - // Fetch subscription info (Stripe with in-memory cache) - subscriptionData := h.buildSubscriptionDisplay(user.DID) - - meta := NewPageMeta( - "Settings - "+h.ClientShortName, - "Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences", - ).WithRobots("noindex"). - WithSiteName(h.ClientShortName) - - data := struct { - PageData - Meta *PageMeta - Profile struct { - Handle string - DID string - PDSEndpoint string - DefaultHold string - AutoRemoveUntagged bool - OciClient string - AIAdvisorEnabled bool - HasAIAdvisorAccess bool // billing tier grants access + memberHolds = append(memberHolds, display) + if display.IsActive { + holdCopy := display + activeHold = &holdCopy + } else { + otherHolds = append(otherHolds, display) } - ActiveHold *HoldDisplay - OtherHolds []HoldDisplay - AllHolds []HoldDisplay - WebhooksData webhooksTemplateData - Subscription SubscriptionDisplay - }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Meta: meta, - ActiveHold: activeHold, - OtherHolds: otherHolds, - AllHolds: allHolds, - WebhooksData: webhooksData, - Subscription: subscriptionData, } - data.Profile.Handle = user.Handle - data.Profile.DID = user.DID - data.Profile.PDSEndpoint = user.PDSEndpoint - data.Profile.DefaultHold = profile.DefaultHold - data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged - data.Profile.OciClient = profile.OciClient - data.Profile.AIAdvisorEnabled = profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled - if h.BillingManager != nil { - data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID) - } - - if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + return activeHold, otherHolds, memberHolds, eligibleHolds } // webhooksTemplateData is the data passed to the webhooks_list template. @@ -248,10 +305,18 @@ func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionD IsCurrent: tier.IsCurrent, } if tier.PriceCentsMonthly > 0 { - td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100) + if tier.PriceCentsMonthly%100 == 0 { + td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100) + } else { + td.PriceMonthly = fmt.Sprintf("$%.2f/mo", float64(tier.PriceCentsMonthly)/100.0) + } } if tier.PriceCentsYearly > 0 { - td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100) + if tier.PriceCentsYearly%100 == 0 { + td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100) + } else { + td.PriceYearly = fmt.Sprintf("$%.2f/yr", float64(tier.PriceCentsYearly)/100.0) + } } display.Tiers = append(display.Tiers, td) } @@ -331,13 +396,10 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ } if !hasAccess { - w.Header().Set("Content-Type", "text/html") - if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ - "Type": "error", - "Message": "You don't have access to this hold", - }); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + // hx-swap="none" on the selector form means an inline alert + // would be discarded — route through RenderHTMXError so + // the client-side toast handler fires instead. + RenderHTMXError(w, r, http.StatusForbidden, "You don't have access to this hold", nil) return } } @@ -359,7 +421,7 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ // Save profile if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { - http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) + RenderHTMXError(w, r, http.StatusInternalServerError, "Couldn't update your default hold", err) return } @@ -381,14 +443,15 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ } } + // Fire a success toast via HX-Trigger in addition to the HX-Refresh — the + // page reloads so the user sees the new hold applied, and the toast + // confirms the action took effect. + trigger, _ := json.Marshal(map[string]map[string]string{ + "toast": {"message": "Default hold updated", "type": "success"}, + }) + w.Header().Set("HX-Trigger", string(trigger)) w.Header().Set("HX-Refresh", "true") - w.Header().Set("Content-Type", "text/html") - if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ - "Type": "success", - "Message": "Default hold updated successfully!", - }); err != nil { - slog.Warn("Failed to render alert", "error", err) - } + w.WriteHeader(http.StatusNoContent) } // UpdateAutoRemoveUntaggedHandler handles toggling the auto-remove-untagged setting diff --git a/pkg/appview/handlers/storage.go b/pkg/appview/handlers/storage.go index 5b11d9d..25a977b 100644 --- a/pkg/appview/handlers/storage.go +++ b/pkg/appview/handlers/storage.go @@ -153,12 +153,21 @@ func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, ho func (h *StorageHandler) renderError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, `
%s
`, message) + // Route through the alert partial so the error matches the rest of the + // UI; previous hand-rolled markup referenced a non-existent + // `storage-error` class. + if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ + "Type": "error", + "Message": message, + }); err != nil { + slog.Error("Failed to render storage alert", "error", err) + fmt.Fprintf(w, `

%s

`, message) + } } func (h *StorageHandler) renderNoHold(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, `
No hold configured. Set a default hold above to see storage usage.
`) + fmt.Fprint(w, `

No hold configured. Set a default hold above to see storage usage.

`) } // humanizeBytes converts bytes to human-readable format diff --git a/pkg/appview/handlers/subscription.go b/pkg/appview/handlers/subscription.go index 115620b..8443317 100644 --- a/pkg/appview/handlers/subscription.go +++ b/pkg/appview/handlers/subscription.go @@ -94,7 +94,7 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req if r.TLS == nil { scheme = "http" } - returnURL := scheme + "://" + h.SiteURL + "/settings#billing" + returnURL := scheme + "://" + h.SiteURL + "/settings/billing" resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL) if err != nil { diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index 9ebbf8a..3d61cac 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -1,7 +1,7 @@ package handlers import ( - "log" + "log/slog" "net/http" "atcr.io/pkg/appview/db" @@ -54,11 +54,16 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { currentUserDID = user.DID } - // Fetch repository cards for this user + // Fetch repository cards. Track the error separately so the template can + // render a distinct error state ("couldn't load their images") rather + // than the empty profile copy ("no images yet"), which implies no push + // has ever happened. + var cardsErr bool cards, err := db.GetUserRepoCards(h.ReadOnlyDB, viewedUser.DID, currentUserDID) if err != nil { - log.Printf("Error fetching repo cards for user %s: %v", viewedUser.DID, err) + slog.Error("user: fetch repo cards", "did", viewedUser.DID, "err", err) cards = []db.RepoCardData{} + cardsErr = true } db.SetRegistryURL(cards, h.RegistryURL) @@ -86,6 +91,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Repositories []db.RepoCardData HasProfile bool SupporterBadge string + HasError bool }{ PageData: pageData, Meta: meta, @@ -93,6 +99,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Repositories: cards, HasProfile: hasProfile, SupporterBadge: supporterBadge, + HasError: cardsErr, } if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil { diff --git a/pkg/appview/handlers/vuln_details.go b/pkg/appview/handlers/vuln_details.go index 041713b..c79db82 100644 --- a/pkg/appview/handlers/vuln_details.go +++ b/pkg/appview/handlers/vuln_details.go @@ -240,9 +240,11 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) h.renderDetails(w, vulnDetailsData{ - Matches: matches, - Summary: summary, - ScannedAt: scanRecord.ScannedAt, + Matches: matches, + Summary: summary, + ScannedAt: scanRecord.ScannedAt, + Digest: digest, + HoldEndpoint: holdDID, }) } diff --git a/pkg/appview/handlers/webhooks.go b/pkg/appview/handlers/webhooks.go index eaf379f..c8c4ab1 100644 --- a/pkg/appview/handlers/webhooks.go +++ b/pkg/appview/handlers/webhooks.go @@ -105,13 +105,22 @@ func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Tier enforcement limits := h.getWebhookLimits(user.DID) - // Check webhook count limit - count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) + // Dedupe: refuse to add a second webhook with the same URL for this user. + // A duplicate is almost always an accidental double-submit and creates + // confusing behavior (same payload fires twice, separate delete buttons). + existing, err := db.ListWebhooks(h.ReadOnlyDB, user.DID) if err != nil { - h.renderWebhookError(w, "Failed to check webhook count") + h.renderWebhookError(w, "Failed to check existing webhooks") return } - if limits.Max >= 0 && count >= limits.Max { + for _, ex := range existing { + if ex.URL == webhookURL { + h.renderWebhookError(w, "A webhook with this URL is already configured") + return + } + } + + if limits.Max >= 0 && len(existing) >= limits.Max { h.renderWebhookError(w, "Webhook limit reached") return } @@ -213,14 +222,17 @@ func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ---- Shared helpers ---- // getWebhookLimits returns the webhook limits for a user based on their billing tier. +// When the billing manager is absent or disabled we treat the deployment as +// "all features free": unlimited webhooks and all trigger types allowed. +// Without this, self-hosted instances without billing config silently capped +// users at 1 webhook with restricted triggers. func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits { - limits := webhookLimits{Max: 1} - if h.BillingManager != nil { - if h.BillingManager.Enabled() { - limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) - } - limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() + if h.BillingManager == nil || !h.BillingManager.Enabled() { + return webhookLimits{Max: -1, AllTriggers: true} } + limits := webhookLimits{Max: 1} + limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) + limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() return limits } @@ -274,6 +286,7 @@ func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db type triggerInfo struct { Name string + FormName string // form field name, e.g. "trigger_push" — set so templates don't need a ternary Bit int Label string Description string @@ -282,12 +295,13 @@ type triggerInfo struct { } // webhookTriggerInfo returns the canonical list of webhook trigger types. +// FormName is the HTML form field name (kept in sync with handler parsing). func webhookTriggerInfo() []triggerInfo { return []triggerInfo{ - {Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, + {Name: "push", FormName: "trigger_push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, + {Name: "scan:first", FormName: "trigger_first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, + {Name: "scan:all", FormName: "trigger_all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, + {Name: "scan:changed", FormName: "trigger_changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, } } diff --git a/pkg/appview/public/icons.svg b/pkg/appview/public/icons.svg index eb4d6b7..b22fd1a 100644 --- a/pkg/appview/public/icons.svg +++ b/pkg/appview/public/icons.svg @@ -19,7 +19,6 @@ - @@ -42,7 +41,10 @@ + + + @@ -65,7 +67,7 @@ - + \ No newline at end of file diff --git a/pkg/appview/public/js/bundle.min.js b/pkg/appview/public/js/bundle.min.js index 4ecd3aa..e694bbe 100644 --- a/pkg/appview/public/js/bundle.min.js +++ b/pkg/appview/public/js/bundle.min.js @@ -1,10 +1,10 @@ -var xe=(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 s=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return s&&(s==="*"||s.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 s=parseHTML(e);takeChildrenFor(r,s.body),r.title=s.title}else if(n==="body"){r=new DocumentFragment;let s=parseHTML(t);takeChildrenFor(r,s.body),r.title=s.title}else{let s=parseHTML('");r=s.querySelector("template").content,r.title=s.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(s=>s.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 i=0,l=0;for(let a=0;a"&&i--}l0;){let i=normalizeSelector(r.shift()),l;i.indexOf("closest ")===0?l=closest(asElement(e),normalizeSelector(i.slice(8))):i.indexOf("find ")===0?l=find(asParentNode(e),normalizeSelector(i.slice(5))):i==="next"||i==="nextElementSibling"?l=asElement(e).nextElementSibling:i.indexOf("next ")===0?l=scanForwardQuery(e,normalizeSelector(i.slice(5)),!!n):i==="previous"||i==="previousElementSibling"?l=asElement(e).previousElementSibling:i.indexOf("previous ")===0?l=scanBackwardsQuery(e,normalizeSelector(i.slice(9)),!!n):i==="document"?l=document:i==="window"?l=window:i==="body"?l=document.body:i==="root"?l=getRootNode(e,!!n):i==="host"?l=e.getRootNode().host:s.push(i),l&&o.push(l)}if(s.length>0){let i=s.join(","),l=asParentNode(getRootNode(e,!!n));o.push(...toArray(l.querySelectorAll(i)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o=0;o--){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return s}};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 s=processEventArgs(e,t,n,r);s.target.addEventListener(s.event,s.listener,s.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 s=asElement(getClosestMatch(e,function(i){return i!==e&&hasAttribute(asElement(i),t)}));s&&r.push(...findAttributeTargets(s,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?(s=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):s=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let i=querySelectorAllExt(r,o,!1);return i.length?(forEach(i,function(l){let a,c=t.cloneNode(!0);a=getDocument().createDocumentFragment(),a.appendChild(c),isInlineSwap(s,l)||(a=asParentNode(c));let d={shouldSwap:!0,target:l,fragment:a};triggerEvent(l,"htmx:oobBeforeSwap",d)&&(l=d.target,d.shouldSwap&&(handlePreservedElements(a),swapWithStyle(s,l,l,a,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",d)}))}),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 s=o.replace("'","\\'"),i=r.tagName.replace(":","\\:"),l=asParentNode(e),a=l&&l.querySelector(i+"[id='"+s+"']");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,s=null,i=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),d=document.activeElement,u={};u={elt:d,start:d?d.selectionStart:null,end:d?d.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(f.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let y=r.selectOOB.split(",");for(let p=0;p0?getWindow().setTimeout(m,n.settleDelay):m()},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,f){o=u,s=f}),d=i;i=function(){document.startViewTransition(function(){return d(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(i,n.swapDelay):i()}catch(c){throw triggerErrorEvent(a,"htmx:swapError",r.eventInfo),maybeCall(s),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let s in o)if(o.hasOwnProperty(s)){let i=o[s];isRawObject(i)?n=i.target!==void 0?i.target:n:i={value:i},triggerEvent(n,s,i)}}else{let o=r.split(",");for(let s=0;s0;){let i=t[0];if(i==="]"){if(r--,r===0){s===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 i==="["&&r++;isPossibleRelativeReference(i,s,n)?o+="(("+n+"."+i+") ? ("+n+"."+i+") : (window."+i+"))":o=o+i,s=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 s=maybeGenerateConditional(e,o,"event");s&&(c.eventFilter=s),r.push(c)}else{let c={trigger:a};var s=maybeGenerateConditional(e,o,"event");for(s&&(c.eventFilter=s),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 i=consumeCSSSelector(o);else{var i=consumeUntil(o,WHITESPACE_OR_COMMA);if(i==="closest"||i==="find"||i==="next"||i==="previous"){o.shift();let m=consumeCSSSelector(o);m.length>0&&(i+=" "+m)}}c.from=i}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 s=getRawAttribute(e,"method");r=s?s.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(s){addEventListener(e,function(i,l){let a=asElement(i);if(eltIsDisabled(a)){cleanUpElement(a);return}issueAjaxRequest(r,o,a,l)},t,s,!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 s=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:s}),!0}return!1}function addEventListener(e,t,n,r,o){let s=getInternalData(e),i;r.from?i=querySelectorAllExt(e,r.from):i=[e],r.changed&&("lastValue"in s||(s.lastValue=new WeakMap),i.forEach(function(l){s.lastValue.has(r)||s.lastValue.set(r,new WeakMap),s.lastValue.get(r).set(l,l.value)})),forEach(i,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 d=getInternalData(c);if(d.triggerSpec=r,d.handledFor==null&&(d.handledFor=[]),d.handledFor.indexOf(e)<0){if(d.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(s.triggeredOnce)return;s.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,m=s.lastValue.get(r);if(m.has(u)&&m.get(u)===f)return;m.set(u,f)}if(s.delayed&&clearTimeout(s.delayed),s.throttle)return;r.throttle>0?s.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),s.throttle=getWindow().setTimeout(function(){s.throttle=null},r.throttle)):r.delay>0?s.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 s=getAttributeValue(e,"hx-"+o);r=!0,t.path=s,t.verb=o,n.forEach(function(i){addTriggerHandler(e,i,t,function(l,a){let c=asElement(l);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,s,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(i){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", "+s).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,s=function(i){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,i))})};e.addEventListener(t,s),r.onHandlers.push({event:t,listener:s})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;thtmx.config.historyCacheSize;)s.shift();for(;s.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(s));break}catch(l){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:l,cache:s}),s.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 s=getRawAttribute(r,"name");addValueToFormData(s,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(s){e.indexOf(s)>=0?removeValueFromFormData(s.name,getValueFromInput(s),t):e.push(s),o&&validateElement(s,n)}),new FormData(r).forEach(function(s,i){s instanceof File&&s.name===""||addValueToFormData(i,s,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,s=[],i=getInternalData(e);i.lastButtonClicked&&!bodyContains(i.lastButtonClicked)&&(i.lastButtonClicked=null);let l=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(i.lastButtonClicked&&(l=l&&i.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,s,getRelatedForm(e),l),processInputValue(n,r,s,e,l),i.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=i.lastButtonClicked||e,d=getRawAttribute(c,"name");addValueToFormData(d,c.value,o)}let a=findAttributeTargets(e,"hx-include");return forEach(a,function(c){processInputValue(n,r,s,asElement(c),l),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(d){processInputValue(n,r,s,d,l)})}),overrideFormData(r,o),{errors:s,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(s){r.append(o,s)})}),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 i=splitOnWhitespace(n);if(i.length>0)for(let l=0;l0?o.join(":"):null;r.scroll=d,r.scrollTarget=s}else if(a.indexOf("show:")===0){var o=a.slice(5).split(":");let u=o.pop();var s=o.length>0?o.join(":"):null;r.show=u,r.showTarget=s}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 i=t.showTarget;t.showTarget==="window"&&(i="body"),o=asElement(querySelectorExt(n,i))}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 s=getAttributeValue(e,t);if(s){let i=s.trim(),l=n;if(i==="unset")return null;i.indexOf("javascript:")===0?(i=i.slice(11),l=!0):i.indexOf("js:")===0&&(i=i.slice(3),l=!0),i.indexOf("{")!==0&&(i="{"+i+"}");let a;l?a=maybeEval(e,function(){return o?Function("event","return ("+i+")").call(e,o):Function("return ("+i+")").call(e)},{}):a=parseJSON(i);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),s=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!s?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:s},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(s){r.push(s),e.append(t,s)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(s){e.append(t,s)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,s){return r[o]=s,e.delete(t),r.forEach(function(i){e.append(t,i)}),!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,s){let i=null,l=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var a=new Promise(function(g,b){i=g,l=b});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,d=o.select||null;if(!bodyContains(n))return maybeCall(i),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 f=getInternalData(n),m=f.lastButtonClicked;if(m){let g=getRawAttribute(m,"formaction");g!=null&&(t=g);let b=getRawAttribute(m,"formmethod");if(b!=null)if(VERBS.includes(b.toLowerCase()))e=b;else return maybeCall(i),a}let h=getClosestAttributeValue(n,"hx-confirm");if(s===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(L){return issueAjaxRequest(e,t,n,r,o,!!L)},question:h})===!1)return maybeCall(i),a;let y=n,p=getClosestAttributeValue(n,"hx-sync"),w=null,T=!1;if(p){let g=p.split(":"),b=g[0].trim();if(b==="this"?y=findThisElement(n,"hx-sync"):y=asElement(querySelectorExt(n,b)),p=(g[1]||"drop").trim(),f=getInternalData(y),p==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(i),a;if(p==="abort"){if(f.xhr)return maybeCall(i),a;T=!0}else p==="replace"?triggerEvent(y,"htmx:abort"):p.indexOf("queue")===0&&(w=(p.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(y,"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 f.queuedRequests==null&&(f.queuedRequests=[]),w==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(i),a}let v=new XMLHttpRequest;f.xhr=v,f.abortable=T;let E=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},Q=getClosestAttributeValue(n,"hx-prompt");if(Q){var U=prompt(Q);if(U===null||!triggerEvent(n,"htmx:prompt",{prompt:U,target:u}))return maybeCall(i),E(),a}if(h&&!s&&!confirm(h))return maybeCall(i),E(),a;let I=getHeaders(n,u,U);e!=="get"&&!usesFormData(n)&&(I["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(I=mergeObjects(I,o.headers));let Z=getInputValues(n,e),q=Z.errors,ee=Z.formData;o.values&&overrideFormData(ee,formDataFromObject(o.values));let ve=formDataFromObject(getExpressionVars(n,r)),V=overrideFormData(ee,ve),H=filterValues(V,n);htmx.config.getCacheBusterParam&&e==="get"&&H.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let j=getValuesForElement(n,"hx-request"),te=getInternalData(n).boosted,k=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,S={boosted:te,useUrlParams:k,formData:H,parameters:formDataProxy(H),unfilteredFormData:V,unfilteredParameters:formDataProxy(V),headers:I,elt:n,target:u,verb:e,errors:q,withCredentials:o.credentials||j.credentials||htmx.config.withCredentials,timeout:o.timeout||j.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",S))return maybeCall(i),E(),a;if(t=S.path,e=S.verb,I=S.headers,H=formDataFromObject(S.parameters),q=S.errors,k=S.useUrlParams,q&&q.length>0)return triggerEvent(n,"htmx:validation:halted",S),maybeCall(i),E(),a;let ne=t.split("#"),be=ne[0],W=ne[1],A=t;if(k&&(A=be,!H.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(H),W&&(A+="#"+W))),!verifyPath(n,A,S))return triggerErrorEvent(n,"htmx:invalidPath",S),maybeCall(l),E(),a;if(v.open(e.toUpperCase(),A,!0),v.overrideMimeType("text/html"),v.withCredentials=S.withCredentials,v.timeout=S.timeout,!j.noHeaders){for(let g in I)if(I.hasOwnProperty(g)){let b=I[g];safelySetHeaderValue(v,g,b)}}let x={xhr:v,target:u,requestConfig:S,etc:o,boosted:te,select:d,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:W}};if(v.onload=function(){try{let g=hierarchyForElt(n);if(x.pathInfo.responsePath=getPathFromResponse(v),c(n,x),x.keepIndicators!==!0&&removeRequestIndicators(N,P),triggerEvent(n,"htmx:afterRequest",x),triggerEvent(n,"htmx:afterOnLoad",x),!bodyContains(n)){let b=null;for(;g.length>0&&b==null;){let L=g.shift();bodyContains(L)&&(b=L)}b&&(triggerEvent(b,"htmx:afterRequest",x),triggerEvent(b,"htmx:afterOnLoad",x))}maybeCall(i)}catch(g){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:g},x)),g}finally{E()}},v.onerror=function(){removeRequestIndicators(N,P),triggerErrorEvent(n,"htmx:afterRequest",x),triggerErrorEvent(n,"htmx:sendError",x),maybeCall(l),E()},v.onabort=function(){removeRequestIndicators(N,P),triggerErrorEvent(n,"htmx:afterRequest",x),triggerErrorEvent(n,"htmx:sendAbort",x),maybeCall(l),E()},v.ontimeout=function(){removeRequestIndicators(N,P),triggerErrorEvent(n,"htmx:afterRequest",x),triggerErrorEvent(n,"htmx:timeout",x),maybeCall(l),E()},!triggerEvent(n,"htmx:beforeRequest",x))return maybeCall(i),E(),a;var N=addRequestIndicatorClasses(n),P=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(g){forEach([v,v.upload],function(b){b.addEventListener(g,function(L){triggerEvent(n,"htmx:xhr:"+g,{lengthComputable:L.lengthComputable,loaded:L.loaded,total:L.total})})})}),triggerEvent(n,"htmx:beforeSend",x);let we=k?null:encodeParamsForBody(v,n,H);return v.send(we),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 s=t.pathInfo.finalRequestPath,i=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,d=null,u=null;return l?(d="push",u=l):a?(d="replace",u=a):c&&(d="push",u=i||s),u?u==="false"?{}:(u==="true"&&(u=i||s),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:d,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,s=getInternalData(o);s&&s.xhr&&s.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})(),D=xe;(function(){let e;D.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(i,l){Object.hasOwn(o,l)?(Array.isArray(o[l])||(o[l]=[o[l]]),o[l].push(i)):o[l]=i});let s=e.getExpressionVars(r);return Object.keys(o).forEach(function(i){o[i]=Object.hasOwn(s,i)?s[i]:o[i]}),JSON.stringify(o)}})})();var re="https://typeahead.waow.tech",se="https://public.api.bsky.app",Te="/xrpc/app.bsky.actor.searchActorsTypeahead",Se="/xrpc/app.bsky.actor.getProfiles";var Ce="atcr_recent_handles",ie="atcr_recent_profile_cache";var z=class{constructor(t){this.input=t,this.container=t.closest(".sailor-typeahead")||t.parentElement,this.dropdown=null,this.selectedCard=null,this.actors=[],this.currentItems=[],this.mode="hidden",this.focusIndex=-1,this.debounceTimer=null,this.requestSeq=0,this.primaryUnhealthyUntil=0,this.lastPrefetchPrefix="",this.lastPrefetchAt=0,this.createDropdown(),this.bindEvents(),this.input.value.trim().length===0&&this.showRecent()}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="sailor-typeahead-dropdown",this.dropdown.setAttribute("role","listbox"),this.dropdown.style.display="none",this.input.insertAdjacentElement("afterend",this.dropdown)}bindEvents(){this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hide()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&this.selectedCard&&this.clearSelection()})}handleFocus(){this.input.value.trim().length===0&&this.showRecent()}handleInput(){let t=this.input.value.trim();if(t.length===0){this.showRecent();return}if(t.length>=2&&t.length<4){this.hide(),this.schedulePrefetch(t);return}if(t.length>=4){this.scheduleSearch(t);return}this.hide()}schedulePrefetch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runPrefetch(t),150)}scheduleSearch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runSearch(t),150)}async runPrefetch(t){let n=Date.now();if(!(t===this.lastPrefetchPrefix&&n-this.lastPrefetchAt<1e4)&&!(n=this.primaryUnhealthyUntil)try{r=await X(re,t,1500)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}if(r===null)try{r=await X(se,t,1500)}catch{r=[]}n===this.requestSeq&&(this.actors=r||[],this.focusIndex=-1,this.renderResults())}renderResults(){if(this.mode="results",this.dropdown.innerHTML="",this.currentItems=[],this.actors.length===0){this.hide();return}this.actors.forEach((t,n)=>{this.currentItems.push(t),this.dropdown.appendChild(this.buildActorRow(t,n))}),this.dropdown.style.display="block"}buildActorRow(t,n){let r=document.createElement("div");r.className="sailor-typeahead-item",r.setAttribute("role","option"),r.dataset.index=String(n),r.dataset.handle=t.handle;let o=document.createElement("div");if(o.className="sailor-typeahead-avatar",t.avatar){let a=document.createElement("img");a.src=t.avatar,a.alt="",a.loading="lazy",o.appendChild(a)}let s=document.createElement("div");s.className="sailor-typeahead-text";let i=t.displayName&&t.displayName!==t.handle;if(i){let a=document.createElement("div");a.className="sailor-typeahead-name",a.textContent=t.displayName,s.appendChild(a)}let l=document.createElement("div");return l.className=i?"sailor-typeahead-handle":"sailor-typeahead-name",l.textContent="@"+t.handle,s.appendChild(l),r.append(o,s),r.addEventListener("mousedown",a=>{a.preventDefault(),this.select(t)}),r}showRecent(){let t=Le();if(t.length===0){this.hide();return}this.mode="recent",this.focusIndex=-1,this.renderRecent(t),this.enrichRecent(t)}renderRecent(t){let n=F();this.dropdown.innerHTML="",this.currentItems=[];let r=document.createElement("div");r.className="sailor-typeahead-header",r.textContent="Recent accounts",this.dropdown.appendChild(r),t.forEach((o,s)=>{let i=n[o]?.profile||{handle:o};this.currentItems.push(i),this.dropdown.appendChild(this.buildActorRow(i,s))}),this.dropdown.style.display="block"}async enrichRecent(t){let n=F(),r=Date.now(),o=t.filter(l=>{let a=n[l];return!a||r-a.ts>864e5});if(o.length===0)return;let s=await Ae(o);if(s.length===0)return;let i=F();s.forEach(l=>{i[l.handle]={ts:r,profile:{handle:l.handle,displayName:l.displayName,avatar:l.avatar}}}),oe(i),this.mode==="recent"&&this.renderRecent(t)}hide(){this.mode="hidden",this.focusIndex=-1,this.dropdown.style.display="none"}select(t){if(typeof t=="string"&&(t={handle:t}),this.input.value=t.handle,this.hide(),this.showSelectedCard(t),t.handle){let n=F();n[t.handle]={ts:Date.now(),profile:{handle:t.handle,displayName:t.displayName,avatar:t.avatar}},oe(n)}}showSelectedCard(t){this.clearSelectedCard();let n=document.createElement("div");n.className="sailor-typeahead-selected";let r=document.createElement("div");if(r.className="sailor-typeahead-avatar",t.avatar){let a=document.createElement("img");a.src=t.avatar,a.alt="",r.appendChild(a)}let o=document.createElement("div");o.className="sailor-typeahead-text";let s=t.displayName&&t.displayName!==t.handle;if(s){let a=document.createElement("div");a.className="sailor-typeahead-name",a.textContent=t.displayName,o.appendChild(a)}let i=document.createElement("div");i.className=s?"sailor-typeahead-handle":"sailor-typeahead-name",i.textContent="@"+t.handle,o.appendChild(i);let l=document.createElement("button");l.type="button",l.className="sailor-typeahead-clear",l.tabIndex=-1,l.setAttribute("aria-label","Change account"),l.innerHTML="×",l.addEventListener("click",()=>this.clearSelection()),n.append(r,o,l),this.input.style.display="none",this.input.insertAdjacentElement("beforebegin",n),this.selectedCard=n}clearSelectedCard(){this.selectedCard&&(this.selectedCard.remove(),this.selectedCard=null)}clearSelection(){this.clearSelectedCard(),this.input.style.display="",this.input.value="",this.input.focus(),this.showRecent()}handleKeydown(t){if(this.mode==="hidden")return;let n=this.dropdown.querySelectorAll(".sailor-typeahead-item");n.length!==0&&(t.key==="ArrowDown"?(t.preventDefault(),this.focusIndex=(this.focusIndex+1)%n.length,this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.focusIndex=this.focusIndex<=0?n.length-1:this.focusIndex-1,this.updateFocus(n)):t.key==="Enter"?this.focusIndex>=0&&this.currentItems[this.focusIndex]&&(t.preventDefault(),this.select(this.currentItems[this.focusIndex])):t.key==="Escape"?this.hide():t.key==="Tab"&&this.focusIndex===-1&&n.length>0&&(t.preventDefault(),this.focusIndex=0,this.updateFocus(n)))}updateFocus(t){t.forEach((n,r)=>{n.classList.toggle("focused",r===this.focusIndex),r===this.focusIndex&&n.scrollIntoView({block:"nearest"})})}};async function X(e,t,n){let r=new URL(Te,e);r.searchParams.set("q",t),r.searchParams.set("limit",String(8));let o=new AbortController,s=setTimeout(()=>o.abort(),n);try{let i=await fetch(r,{signal:o.signal});if(!i.ok)throw new Error("HTTP "+i.status);let l=await i.json();return Array.isArray(l.actors)?l.actors:[]}finally{clearTimeout(s)}}async function Ae(e){if(e.length===0)return[];let t=new URL(Se,se);e.forEach(o=>t.searchParams.append("actors",o));let n=new AbortController,r=setTimeout(()=>n.abort(),3e3);try{let o=await fetch(t,{signal:n.signal});if(!o.ok)return[];let s=await o.json();return Array.isArray(s.profiles)?s.profiles:[]}catch{return[]}finally{clearTimeout(r)}}function F(){try{return JSON.parse(localStorage.getItem(ie)||"{}")}catch{return{}}}function oe(e){try{localStorage.setItem(ie,JSON.stringify(e))}catch{}}function Le(){try{let e=localStorage.getItem(Ce);return e?JSON.parse(e):[]}catch{return[]}}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("handle");e&&new z(e)});function ue(){return localStorage.getItem("theme")||"system"}function Ie(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function J(){let e=ue(),n=Ie(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),He(e)}function de(e){localStorage.setItem("theme",e),J(),De()}function He(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e;n.setAttribute("aria-checked",r?"true":"false");let o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function De(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{ue()==="system"&&J()});function fe(e,t){if(!e)return;let n=e.querySelector(".nav-search-form"),r=e.querySelector('button[aria-controls="nav-search-form"]');e.classList.toggle("expanded",t),n&&(t?n.removeAttribute("inert"):n.setAttribute("inert","")),r&&r.setAttribute("aria-expanded",t?"true":"false")}function Re(){let e=document.querySelector(".nav-search-wrapper");if(!e)return;let t=!e.classList.contains("expanded");if(fe(e,t),t){let n=document.getElementById("nav-search-input");n&&n.focus()}}function ae(){fe(document.querySelector(".nav-search-wrapper"),!1)}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{if(n.key==="Escape"&&e.classList.contains("expanded")&&ae(),n.key==="/"&&!e.classList.contains("expanded")){let r=n.target.tagName;if(r==="INPUT"||r==="TEXTAREA"||n.target.isContentEditable)return;n.preventDefault(),e.classList.add("expanded"),t.focus()}}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&ae()}))});function $(e,t){let n=()=>{if(!t)return;let r=t.innerHTML;t.innerHTML=' Copied!',setTimeout(()=>{t.innerHTML=r},2e3)};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(e).then(n).catch(r=>{console.error("Clipboard API failed, falling back:",r),le(e)?n():C("Copy failed \u2014 check browser permissions","error")});return}le(e)?n():C("Copy failed \u2014 select the text and copy manually","error")}function le(e){let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.setAttribute("aria-hidden","true"),t.style.position="fixed",t.style.top="0",t.style.left="0",t.style.width="1px",t.style.height="1px",t.style.opacity="0",t.style.pointerEvents="none",document.body.appendChild(t);let n=!1;try{t.focus(),t.select(),t.setSelectionRange(0,e.length),n=document.execCommand&&document.execCommand("copy")}catch{n=!1}return document.body.removeChild(t),!!n}function Oe(e){let t=s=>{let i=(s==null?"":String(s)).replace(/\s+/g," ").trim();return/[",\n\r]/.test(i)?'"'+i.replace(/"/g,'""')+'"':i},n=s=>Array.from(s).map(i=>t(i.textContent)).join(","),r=[],o=e.querySelector("thead tr");return o&&r.push(n(o.querySelectorAll("th,td"))),e.querySelectorAll("tbody tr").forEach(s=>{r.push(n(s.querySelectorAll("td,th")))}),r.join(` -`)}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-copy-csv]");if(t){let r=t.closest("[data-csv-section]"),o=r&&r.querySelector("table");o&&$(Oe(o),t);return}let n=e.target.closest("button[data-cmd]");if(n){$(n.getAttribute("data-cmd"),n);return}})});function Me(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let s=Math.floor(t/o);if(s>=1)return s===1?`1 ${r} ago`:`${s} ${r}s ago`}return"just now"}function B(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=Me(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{B(),J(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{de(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",B);var R=null;function he(){R===null&&(R=setInterval(B,6e4))}function qe(){R!==null&&(clearInterval(R),R=null)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?qe():(B(),he())});he();async function ke(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();Ne(e,t,n,o.tags)}else if(r.ok)me(n);else{let o=await r.text();C(`Failed to delete manifest: ${o||r.status}`,"error")}}catch(r){console.error("Error deleting manifest:",r),C(`Error deleting manifest: ${r.message}`,"error")}}function Ne(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),s=document.getElementById("manifest-delete-tags"),i=document.getElementById("confirm-manifest-delete-btn");s.innerHTML="",r.forEach(l=>{let a=document.createElement("li");a.textContent=l,s.appendChild(a)}),i.onclick=()=>Pe(e,t,n),K(o)}function Y(){O(document.getElementById("manifest-delete-modal"))}async function Pe(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let s=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(s.ok)Y(),me(n),location.reload();else{let i=await s.text();C(`Failed to delete manifest: ${i||s.status}`,"error"),r.disabled=!1,r.textContent=o}}catch(s){console.error("Error deleting manifest:",s),C(`Error deleting manifest: ${s.message}`,"error"),r.disabled=!1,r.textContent=o}}async function Fe(e){let t=document.getElementById("confirm-untagged-delete-btn"),n=t.textContent;try{t.disabled=!0,t.textContent="Deleting...";let r=await fetch("/api/manifests/untagged",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e})}),o=await r.json();r.ok?(O(document.getElementById("untagged-delete-modal")),C(`Deleted ${o.deleted} untagged manifest(s)`,"success"),o.deleted>0&&location.reload(),t.disabled=!1,t.textContent=n):(C(`Failed to delete untagged manifests: ${o.error||"Unknown error"}`,"error"),t.disabled=!1,t.textContent=n)}catch(r){console.error("Error deleting untagged manifests:",r),C(`Error: ${r.message}`,"error"),t.disabled=!1,t.textContent=n}}function me(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&Y()})});var M=new WeakMap;function K(e,t){if(e&&(M.set(e,t||document.activeElement),typeof e.showModal=="function")){e.open&&(e.open=!1);try{e.showModal()}catch{}}}function O(e,{remove:t=!1}={}){if(!e)return;let n=M.get(e);if(M.delete(e),typeof e.close=="function"&&e.open)try{e.close()}catch{}t&&e.remove(),ge(n)}function ge(e){e&&typeof e.focus=="function"&&document.contains(e)&&e.focus()}document.addEventListener("close",e=>{let t=e.target;if(!(t instanceof HTMLDialogElement))return;let n=M.get(t);M.delete(t),ge(n)},!0);document.body.addEventListener("htmx:afterSettle",()=>{document.querySelectorAll("dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])").forEach(t=>{t.dataset.modalPromoted="1",K(t)})});document.addEventListener("change",e=>{let t=e.target.closest("select[data-diff-url]");if(!t)return;let n=t.dataset.diffUrl;n&&(window.location.href=n.replace("__VALUE__",encodeURIComponent(t.value)))});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("pull-cmd-container");if(!e)return;let t=e.dataset.registryUrl,n=e.dataset.ownerHandle,r=e.dataset.repoName,o=e.dataset.tag||"latest",s=e.dataset.isLoggedIn==="true";function i(a){let d=(a==="none"?"":a+" pull ")+t+"/"+n+"/"+r+":"+o,u=document.getElementById("pull-cmd-display");if(!u)return;let f=u.querySelector("code");f&&(f.textContent=d);let m=u.querySelector("[data-cmd]");m&&(m.dataset.cmd=d),s&&window.htmx?window.htmx.ajax("POST","/api/profile/oci-client",{values:{oci_client:a},swap:"none"}):s||localStorage.setItem("oci-client",a)}if(!s){let a=localStorage.getItem("oci-client");if(a){let c=document.getElementById("oci-client-switcher");c&&(c.value=a,i(a))}}let l=document.getElementById("oci-client-switcher");l&&l.addEventListener("change",()=>i(l.value))});document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll(".platform-tab[data-platform]");e.length&&e.forEach(t=>{t.addEventListener("click",()=>{e.forEach(r=>{let o=r===t;r.classList.toggle("btn-primary",o),r.classList.toggle("btn-ghost",!o)}),document.querySelectorAll(".platform-content").forEach(r=>r.classList.add("hidden"));let n=document.getElementById(t.dataset.platform+"-content");n&&n.classList.remove("hidden")})})});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form");e&&e.addEventListener("submit",()=>{let t=e.querySelector('button[type="submit"]');!t||t.disabled||(t.disabled=!0,t.innerHTML=' Navigating…')})});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t){try{let n="atcr_recent_handles",r=JSON.parse(localStorage.getItem(n)||"[]");r=r.filter(o=>o!==t),r.unshift(t),r=r.slice(0,5),localStorage.setItem(n,JSON.stringify(r))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function ce(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=e.querySelectorAll(".carousel-item");if(r.length===0)return;let o=null,s=5e3,i=0,l=0;function a(){let m=parseFloat(getComputedStyle(e).gap)||24;i=r[0].offsetWidth+m}a(),window.addEventListener("resize",()=>{cancelAnimationFrame(l),l=requestAnimationFrame(a)}),document.body.addEventListener("htmx:afterSettle",m=>{m.target&&m.target.contains&&m.target.contains(e)&&a()});function c(){let m=e.scrollWidth-e.clientWidth;e.scrollLeft>=m-10?e.scrollTo({left:0,behavior:"smooth"}):e.scrollBy({left:i,behavior:"smooth"})}function d(){e.scrollLeft<=10?e.scrollTo({left:e.scrollWidth,behavior:"smooth"}):e.scrollBy({left:-i,behavior:"smooth"})}function u(){o||document.visibilityState!=="hidden"&&(e.scrollWidth<=e.clientWidth+10||(o=setInterval(c,s)))}function f(){o&&(clearInterval(o),o=null)}t&&t.addEventListener("click",()=>{f(),d(),u()}),n&&n.addEventListener("click",()=>{f(),c(),u()}),e.addEventListener("mouseenter",f),e.addEventListener("mouseleave",u),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?f():u()}),u()}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(ce,{timeout:2e3}):setTimeout(ce,100)});var Be=4,_e=1500;function C(e,t){let n=document.getElementById("toast-container");n||(n=document.createElement("div"),n.id="toast-container",n.className="toast toast-end toast-bottom z-50",n.setAttribute("aria-live","polite"),n.setAttribute("aria-atomic","false"),document.body.appendChild(n));let r=(t||"info")+"|"+e,o=Date.now(),s=n.querySelector(`[data-toast-key="${Ve(r)}"]`);if(s&&o-Number(s.dataset.toastAt)<_e){Ue(s);return}let i=t==="error",l=i?"alert-error":"alert-success",a=document.createElement("div");a.className=`alert ${l} shadow-lg transition-opacity duration-300`,a.style.willChange="opacity",a.setAttribute("role",i?"alert":"status"),a.dataset.toastKey=r,a.dataset.toastAt=String(o);let c=document.createElement("span");for(c.textContent=e,a.appendChild(c),n.appendChild(a);n.children.length>Be;)n.firstElementChild.remove();pe(a)}function pe(e){e._dismissTimer=setTimeout(()=>{e.style.opacity="0",e._removeTimer=setTimeout(()=>e.remove(),300)},3e3)}function Ue(e){clearTimeout(e._dismissTimer),clearTimeout(e._removeTimer),e.style.opacity="",e.dataset.toastAt=String(Date.now()),pe(e)}function Ve(e){return window.CSS&&CSS.escape?CSS.escape(e):String(e).replace(/[^a-zA-Z0-9_-]/g,t=>"\\"+t)}async function je(e){try{let t=await fetch(`/api/webhooks/${e}/test`,{method:"POST",credentials:"include"}),n=await t.text();n.includes('class="success"')||t.ok&&!n.includes('class="error"')?C("Test webhook delivered successfully!","success"):C("Test delivery failed \u2014 check the webhook URL","error")}catch{C("Failed to reach server","error")}}(function(){let t={"switch-repo-tab":s=>window.switchRepoTab&&window.switchRepoTab(s.dataset.tab),"switch-editor-tab":s=>window.switchEditorTab&&window.switchEditorTab(s.dataset.tab),"insert-md":s=>window.insertMd&&window.insertMd(s.dataset.mdType),"toggle-editor":s=>window.toggleOverviewEditor&&window.toggleOverviewEditor(s.dataset.show==="true"),"show-modal":s=>K(document.getElementById(s.dataset.modalId),s),"close-dialog":s=>O(s.closest("dialog")),"remove-closest-dialog":s=>O(s.closest("dialog"),{remove:!0}),"close-manifest-delete-modal":()=>window.closeManifestDeleteModal&&window.closeManifestDeleteModal(),"save-overview":()=>window.saveOverview&&window.saveOverview(),"delete-manifest":s=>window.deleteManifest&&window.deleteManifest(s.dataset.repo,s.dataset.digest,s.dataset.manifestId||""),"delete-untagged":s=>window.deleteUntaggedManifests&&window.deleteUntaggedManifests(s.dataset.repo),copy:s=>window.copyToClipboard&&window.copyToClipboard(s.dataset.copy,s),"toggle-search":()=>window.toggleSearch&&window.toggleSearch(),"switch-settings-tab":s=>window.switchSettingsTab&&window.switchSettingsTab(s.dataset.tab),"test-webhook":s=>window.testWebhook&&window.testWebhook(s.dataset.webhookId),"diff-to":(s,i)=>window.diffToTag&&window.diffToTag(i,s),"modal-backdrop-close":(s,i)=>{i.target===s&&O(s,{remove:!0})}},n={"sort-tags":s=>window.sortTags&&window.sortTags(s.value),"submit-form":s=>s.form&&s.form.requestSubmit()},r={"filter-tags":s=>window.filterTags&&window.filterTags(s.value)};function o(s,i){let l=i.target.closest("[data-action]");if(!l)return;let a=s[l.dataset.action];a&&a(l,i)}document.addEventListener("click",s=>o(t,s)),document.addEventListener("change",s=>o(n,s)),document.addEventListener("input",s=>o(r,s))})();window.setTheme=de;window.toggleSearch=Re;window.copyToClipboard=$;window.deleteManifest=ke;window.deleteUntaggedManifests=Fe;window.closeManifestDeleteModal=Y;window.showToast=C;window.testWebhook=je;function We(){let e=document.getElementById("md-editor");if(!e)return;let t=e.dataset.ownerDid,n=e.dataset.repository;window.toggleOverviewEditor=function(r){document.getElementById("overview-view").classList.toggle("hidden",r),document.getElementById("overview-edit").classList.toggle("hidden",!r),r&&e.focus()},window.switchEditorTab=function(r){if(document.querySelectorAll(".editor-panel").forEach(o=>o.classList.add("hidden")),document.getElementById(r==="write"?"editor-write":"editor-preview").classList.remove("hidden"),document.querySelectorAll(".editor-tab").forEach(o=>{let s=o.dataset.tab===r;o.classList.toggle("border-primary",s),o.classList.toggle("text-primary",s),o.classList.toggle("border-transparent",!s),o.classList.toggle("text-base-content/60",!s)}),r==="preview"){let o=e.value,s=document.getElementById("preview-content");if(!o.trim()){s.innerHTML='

Nothing to preview

';return}s.innerHTML='

Rendering preview…

';let i=new FormData;i.append("markdown",o),fetch("/api/repo-page/preview",{method:"POST",body:i}).then(l=>{if(!l.ok)throw new Error("HTTP "+l.status);return l.text()}).then(l=>{s.innerHTML=l}).catch(()=>{s.innerHTML='

Preview failed. Check your connection and try again.

'})}},window.insertMd=function(r){let o=e.selectionStart,s=e.selectionEnd,i=e.value.substring(o,s),l=e.value.substring(0,o),a=e.value.substring(s),c,d,u;switch(r){case"heading":c="## "+(i||"Heading"),d=o+3,u=o+c.length;break;case"bold":c="**"+(i||"bold text")+"**",d=o+2,u=o+c.length-2;break;case"italic":c="_"+(i||"italic text")+"_",d=o+1,u=o+c.length-1;break;case"link":c="["+(i||"link text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"image":c="!["+(i||"alt text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"ul":c="- "+(i||"list item"),d=o+2,u=o+c.length;break;case"ol":c="1. "+(i||"list item"),d=o+3,u=o+c.length;break;case"code":i&&i.indexOf(` -`)!==-1?(c="```\n"+i+"\n```",d=o+4,u=o+4+i.length):(c="`"+(i||"code")+"`",d=o+1,u=o+c.length-1);break;default:return}e.value=l+c+a,e.focus(),e.selectionStart=d,e.selectionEnd=u},window.saveOverview=function(){let r=document.getElementById("save-overview-btn");r.classList.add("btn-disabled"),r.innerHTML=' Saving...';let o=new FormData;o.append("did",t),o.append("repository",n),o.append("description",e.value),fetch("/api/repo-page",{method:"POST",body:o,headers:{"HX-Request":"true"}}).then(s=>s.ok?s.text():s.text().then(i=>{throw new Error(i)})).then(s=>{document.getElementById("overview-rendered").innerHTML=s,window.toggleOverviewEditor(!1),typeof window.showToast=="function"&&window.showToast("Overview saved","success")}).catch(s=>{typeof window.showToast=="function"&&window.showToast(s.message||"Failed to save","error")}).finally(()=>{r.classList.remove("btn-disabled"),r.innerHTML="Save"})},e.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="s"&&(r.preventDefault(),window.saveOverview())})}window.sortTags=function(e){let t=document.getElementById("tags-list");if(!t)return;let n=Array.from(t.querySelectorAll(".artifact-entry"));n.sort((r,o)=>{switch(e){case"oldest":return parseInt(r.dataset.created)-parseInt(o.dataset.created);case"az":return r.dataset.tag.localeCompare(o.dataset.tag);case"za":return o.dataset.tag.localeCompare(r.dataset.tag);default:return parseInt(o.dataset.created)-parseInt(r.dataset.created)}}),n.forEach(r=>t.appendChild(r))};var _=0;window.filterTags=function(e){_&&cancelAnimationFrame(_),_=requestAnimationFrame(()=>{_=0;let t=e.toLowerCase();document.querySelectorAll("#tags-list .artifact-entry").forEach(n=>{n.style.display=!t||n.dataset.tag.toLowerCase().includes(t)?"":"none"})})};function Xe(){if(!document.getElementById("tag-content"))return;let e=["overview","layers","vulns","sbom","artifacts"],t={};function n(i,l){if(t[i])return;t[i]=!0;let a=document.getElementById(i);if(!a)return;let c=new AbortController,d=setTimeout(()=>c.abort(),1e4);fetch(l,{signal:c.signal}).then(u=>{if(!u.ok)throw new Error("HTTP "+u.status);return u.text()}).then(u=>{a.innerHTML=u,a.querySelectorAll("script").forEach(f=>{let m=document.createElement("script");m.textContent=f.textContent,f.parentNode.replaceChild(m,f)}),typeof window.htmx<"u"&&window.htmx.process(a)}).catch(u=>{t[i]=!1;let m=u&&u.name==="AbortError"?"This section took too long to load.":"Couldn't load this section.";a.innerHTML='

'+m+'

'}).finally(()=>clearTimeout(d))}document.body.addEventListener("click",i=>{let l=i.target.closest("[data-retry-section]");if(!l)return;let a=l.getAttribute("data-retry-section"),d={"artifacts-content":o,"layers-content":()=>r("layers"),"vulns-content":()=>r("vulns"),"sbom-content":()=>r("sbom")}[a];if(d){let u=d();u&&n(a,u)}});function r(i){let l=document.getElementById("tag-content");if(!l)return null;let a=l.dataset.digest;return a?"/api/digest-content/"+l.dataset.owner+"/"+l.dataset.repo+"?digest="+encodeURIComponent(a)+"§ion="+i:null}function o(){let i=document.getElementById("tag-content");return i?"/api/repo-tags/"+i.dataset.owner+"/"+i.dataset.repo:null}window.diffToTag=function(i,l){i.preventDefault();let a=l.dataset.diffTo,c=document.getElementById("tag-content"),d=document.getElementById("tag-selector");if(!c||!d||!a)return;let u=c.dataset.digest,f=d.value;!u||a===f||(window.location.href="/diff/"+c.dataset.owner+"/"+c.dataset.repo+"?from="+encodeURIComponent(u)+"&to="+encodeURIComponent(a))},window.switchRepoTab=function(i){window._activeRepoTab=i;let l=document.getElementById("tag-content");if(!l)return;l.querySelectorAll(".repo-panel").forEach(d=>d.classList.add("hidden"));let a=document.getElementById("tab-"+i);a&&a.classList.remove("hidden"),l.querySelectorAll(".repo-tab").forEach(d=>{let u=d.dataset.tab===i;d.classList.toggle("border-primary",u),d.classList.toggle("text-primary",u),d.classList.toggle("border-transparent",!u),d.classList.toggle("text-base-content/60",!u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")});let c=new URL(window.location);if(c.hash=i,history.replaceState(null,"",c.toString()),i==="artifacts"){let d=o();d&&n("artifacts-content",d)}if(i==="layers"){let d=r("layers");d&&n("layers-content",d)}if(i==="vulns"){let d=r("vulns");d&&n("vulns-content",d)}if(i==="sbom"){let d=r("sbom");d&&n("sbom-content",d)}};function s(){t={},[["artifacts-tab-btn","artifacts-content",o],["layers-tab-btn","layers-content",()=>r("layers")],["vulns-tab-btn","vulns-content",()=>r("vulns")],["sbom-tab-btn","sbom-content",()=>r("sbom")]].forEach(([c,d,u])=>{let f=document.getElementById(c);f&&f.addEventListener("mouseenter",()=>{let m=u();m&&n(d,m)},{once:!0})});let l=document.querySelector('[role="tablist"][aria-label="Repository sections"]');l&&!l.dataset.keyboardBound&&(l.dataset.keyboardBound="1",l.addEventListener("keydown",c=>{let d=Array.from(l.querySelectorAll(".repo-tab")),u=d.indexOf(document.activeElement);if(u===-1)return;let f=-1;switch(c.key){case"ArrowRight":f=(u+1)%d.length;break;case"ArrowLeft":f=(u-1+d.length)%d.length;break;case"Home":f=0;break;case"End":f=d.length-1;break;case"Enter":case" ":c.preventDefault(),window.switchRepoTab(d[u].dataset.tab);return;default:return}c.preventDefault(),d[f].focus()}));let a=window._activeRepoTab||window.location.hash.replace("#","")||"overview";e.indexOf(a)===-1&&(a="overview"),window.switchRepoTab(a)}s(),document.addEventListener("keydown",i=>{if(i.target.tagName==="INPUT"||i.target.tagName==="TEXTAREA"||i.target.tagName==="SELECT"||i.target.isContentEditable||i.ctrlKey||i.metaKey||i.altKey)return;let a={o:"overview",l:"layers",v:"vulns",s:"sbom",a:"artifacts"}[i.key.toLowerCase()];a&&e.indexOf(a)!==-1&&window.switchRepoTab(a)}),document.body.addEventListener("htmx:afterSettle",i=>{i.detail.target&&i.detail.target.id==="tag-content"&&s()})}document.addEventListener("DOMContentLoaded",()=>{We(),Xe()});function ze(){let e=["user","billing","storage","devices","webhooks","advanced"];if(!document.querySelector(".settings-tab-mobile, .menu li[data-tab]"))return;function t(a){document.querySelectorAll(".settings-panel").forEach(d=>d.classList.add("hidden"));let c=document.getElementById("tab-"+a);c&&c.classList.remove("hidden"),document.querySelectorAll(".menu li[data-tab]").forEach(d=>{let u=d.dataset.tab===a;d.classList.toggle("menu-active",u);let f=d.querySelector('a[role="tab"]');f&&(f.setAttribute("aria-selected",u?"true":"false"),f.setAttribute("tabindex",u?"0":"-1"))}),document.querySelectorAll(".settings-tab-mobile").forEach(d=>{let u=d.dataset.tab===a;d.classList.toggle("btn-ghost",!u),d.classList.toggle("btn-secondary",u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")}),history.replaceState(null,"","#"+a),document.body.dispatchEvent(new CustomEvent("tab:"+a))}window.isTabActive=function(a){let c=document.getElementById("tab-"+a);return c&&!c.classList.contains("hidden")},window.switchSettingsTab=t;function n(a,c){let d=c==="vertical"?"ArrowUp":"ArrowLeft",u=c==="vertical"?"ArrowDown":"ArrowRight";return function(f){let m=a.indexOf(f.currentTarget);if(m===-1)return;let h=null;f.key===d?h=a[(m-1+a.length)%a.length]:f.key===u?h=a[(m+1)%a.length]:f.key==="Home"?h=a[0]:f.key==="End"&&(h=a[a.length-1]),h&&(f.preventDefault(),t(h.dataset.tab||h.parentElement.dataset.tab),h.focus())}}let r=Array.from(document.querySelectorAll(".settings-tab-mobile")),o=n(r,"horizontal");r.forEach(a=>{a.addEventListener("click",c=>{c.preventDefault(),t(a.dataset.tab)}),a.addEventListener("keydown",o)});let s=Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')),i=n(s,"vertical");s.forEach(a=>{a.addEventListener("click",c=>{c.preventDefault(),t(a.parentElement.dataset.tab)}),a.addEventListener("keydown",i)});let l=window.location.hash.replace("#","")||"user";e.indexOf(l)===-1&&(l="user"),t(l),window.addEventListener("hashchange",()=>{let a=window.location.hash.replace("#","")||"user";e.indexOf(a)!==-1&&t(a)})}function $e(){let e=document.getElementById("delete-account-btn");if(!e)return;let t=e.dataset.clientShortName||"this account",r="DELETE "+(e.dataset.profileHandle||"");function o(i){let l=document.createElement("div");return l.textContent=i,l.innerHTML}e.addEventListener("click",s);function s(){let i=document.getElementById("delete-pds-records").checked,l=document.createElement("div");l.className="modal modal-open",l.innerHTML=` +var Le=(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 s=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return s&&(s==="*"||s.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 s=parseHTML(e);takeChildrenFor(r,s.body),r.title=s.title}else if(n==="body"){r=new DocumentFragment;let s=parseHTML(t);takeChildrenFor(r,s.body),r.title=s.title}else{let s=parseHTML('");r=s.querySelector("template").content,r.title=s.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(s=>s.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 i=0,a=0;for(let l=0;l"&&i--}a0;){let i=normalizeSelector(r.shift()),a;i.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(i.slice(8))):i.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(i.slice(5))):i==="next"||i==="nextElementSibling"?a=asElement(e).nextElementSibling:i.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(i.slice(5)),!!n):i==="previous"||i==="previousElementSibling"?a=asElement(e).previousElementSibling:i.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(i.slice(9)),!!n):i==="document"?a=document:i==="window"?a=window:i==="body"?a=document.body:i==="root"?a=getRootNode(e,!!n):i==="host"?a=e.getRootNode().host:s.push(i),a&&o.push(a)}if(s.length>0){let i=s.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(i)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o=0;o--){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return s}};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 s=processEventArgs(e,t,n,r);s.target.addEventListener(s.event,s.listener,s.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 s=asElement(getClosestMatch(e,function(i){return i!==e&&hasAttribute(asElement(i),t)}));s&&r.push(...findAttributeTargets(s,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?(s=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):s=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let i=querySelectorAllExt(r,o,!1);return i.length?(forEach(i,function(a){let l,c=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(s,a)||(l=asParentNode(c));let d={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",d)&&(a=d.target,d.shouldSwap&&(handlePreservedElements(l),swapWithStyle(s,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",d)}))}),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 s=o.replace("'","\\'"),i=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(i+"[id='"+s+"']");if(l&&l!==a){let c=r.cloneNode();cloneAttributes(r,l),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,s=null,i=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),d=document.activeElement,u={};u={elt:d,start:d?d.selectionStart:null,end:d?d.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(f.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let g=r.selectOOB.split(",");for(let p=0;p0?getWindow().setTimeout(m,n.settleDelay):m()},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 c=new Promise(function(u,f){o=u,s=f}),d=i;i=function(){document.startViewTransition(function(){return d(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(i,n.swapDelay):i()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(s),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let s in o)if(o.hasOwnProperty(s)){let i=o[s];isRawObject(i)?n=i.target!==void 0?i.target:n:i={value:i},triggerEvent(n,s,i)}}else{let o=r.split(",");for(let s=0;s0;){let i=t[0];if(i==="]"){if(r--,r===0){s===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 i==="["&&r++;isPossibleRelativeReference(i,s,n)?o+="(("+n+"."+i+") ? ("+n+"."+i+") : (window."+i+"))":o=o+i,s=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 c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var s=maybeGenerateConditional(e,o,"event");s&&(c.eventFilter=s),r.push(c)}else{let c={trigger:l};var s=maybeGenerateConditional(e,o,"event");for(s&&(c.eventFilter=s),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 i=consumeCSSSelector(o);else{var i=consumeUntil(o,WHITESPACE_OR_COMMA);if(i==="closest"||i==="find"||i==="next"||i==="previous"){o.shift();let m=consumeCSSSelector(o);m.length>0&&(i+=" "+m)}}c.from=i}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===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 s=getRawAttribute(e,"method");r=s?s.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(s){addEventListener(e,function(i,a){let l=asElement(i);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,s,!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 s=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:s}),!0}return!1}function addEventListener(e,t,n,r,o){let s=getInternalData(e),i;r.from?i=querySelectorAllExt(e,r.from):i=[e],r.changed&&("lastValue"in s||(s.lastValue=new WeakMap),i.forEach(function(a){s.lastValue.has(r)||s.lastValue.set(r,new WeakMap),s.lastValue.get(r).set(a,a.value)})),forEach(i,function(a){let l=function(c){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let d=getInternalData(c);if(d.triggerSpec=r,d.handledFor==null&&(d.handledFor=[]),d.handledFor.indexOf(e)<0){if(d.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(s.triggeredOnce)return;s.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,m=s.lastValue.get(r);if(m.has(u)&&m.get(u)===f)return;m.set(u,f)}if(s.delayed&&clearTimeout(s.delayed),s.throttle)return;r.throttle>0?s.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),s.throttle=getWindow().setTimeout(function(){s.throttle=null},r.throttle)):r.delay>0?s.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: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 s=getAttributeValue(e,"hx-"+o);r=!0,t.path=s,t.verb=o,n.forEach(function(i){addTriggerHandler(e,i,t,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,s,c,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(i){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", "+s).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,s=function(i){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,i))})};e.addEventListener(t,s),r.onHandlers.push({event:t,listener:s})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;thtmx.config.historyCacheSize;)s.shift();for(;s.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(s));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:s}),s.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 s=getRawAttribute(r,"name");addValueToFormData(s,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(s){e.indexOf(s)>=0?removeValueFromFormData(s.name,getValueFromInput(s),t):e.push(s),o&&validateElement(s,n)}),new FormData(r).forEach(function(s,i){s instanceof File&&s.name===""||addValueToFormData(i,s,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,s=[],i=getInternalData(e);i.lastButtonClicked&&!bodyContains(i.lastButtonClicked)&&(i.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(i.lastButtonClicked&&(a=a&&i.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,s,getRelatedForm(e),a),processInputValue(n,r,s,e,a),i.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=i.lastButtonClicked||e,d=getRawAttribute(c,"name");addValueToFormData(d,c.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(c){processInputValue(n,r,s,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(d){processInputValue(n,r,s,d,a)})}),overrideFormData(r,o),{errors:s,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(s){r.append(o,s)})}),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 i=splitOnWhitespace(n);if(i.length>0)for(let a=0;a0?o.join(":"):null;r.scroll=d,r.scrollTarget=s}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let u=o.pop();var s=o.length>0?o.join(":"):null;r.show=u,r.showTarget=s}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);r.focusScroll=c=="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 i=t.showTarget;t.showTarget==="window"&&(i="body"),o=asElement(querySelectorExt(n,i))}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 s=getAttributeValue(e,t);if(s){let i=s.trim(),a=n;if(i==="unset")return null;i.indexOf("javascript:")===0?(i=i.slice(11),a=!0):i.indexOf("js:")===0&&(i=i.slice(3),a=!0),i.indexOf("{")!==0&&(i="{"+i+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+i+")").call(e,o):Function("return ("+i+")").call(e)},{}):l=parseJSON(i);for(let c in l)l.hasOwnProperty(c)&&r[c]==null&&(r[c]=l[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),s=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!s?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:s},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(s){r.push(s),e.append(t,s)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(s){e.append(t,s)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,s){return r[o]=s,e.delete(t),r.forEach(function(i){e.append(t,i)}),!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,s){let i=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(E,x){i=E,a=x});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,d=o.select||null;if(!bodyContains(n))return maybeCall(i),l;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let f=getInternalData(n),m=f.lastButtonClicked;if(m){let E=getRawAttribute(m,"formaction");E!=null&&(t=E);let x=getRawAttribute(m,"formmethod");if(x!=null)if(VERBS.includes(x.toLowerCase()))e=x;else return maybeCall(i),l}let h=getClosestAttributeValue(n,"hx-confirm");if(s===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(L){return issueAjaxRequest(e,t,n,r,o,!!L)},question:h})===!1)return maybeCall(i),l;let g=n,p=getClosestAttributeValue(n,"hx-sync"),y=null,w=!1;if(p){let E=p.split(":"),x=E[0].trim();if(x==="this"?g=findThisElement(n,"hx-sync"):g=asElement(querySelectorExt(n,x)),p=(E[1]||"drop").trim(),f=getInternalData(g),p==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(i),l;if(p==="abort"){if(f.xhr)return maybeCall(i),l;w=!0}else p==="replace"?triggerEvent(g,"htmx:abort"):p.indexOf("queue")===0&&(y=(p.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(g,"htmx:abort");else{if(y==null){if(r){let E=getInternalData(r);E&&E.triggerSpec&&E.triggerSpec.queue&&(y=E.triggerSpec.queue)}y==null&&(y="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),y==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(i),l}let b=new XMLHttpRequest;f.xhr=b,f.abortable=w;let v=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},te=getClosestAttributeValue(n,"hx-prompt");if(te){var V=prompt(te);if(V===null||!triggerEvent(n,"htmx:prompt",{prompt:V,target:u}))return maybeCall(i),v(),l}if(h&&!s&&!confirm(h))return maybeCall(i),v(),l;let H=getHeaders(n,u,V);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let ne=getInputValues(n,e),N=ne.errors,re=ne.formData;o.values&&overrideFormData(re,formDataFromObject(o.values));let Se=formDataFromObject(getExpressionVars(n,r)),j=overrideFormData(re,Se),R=filterValues(j,n);htmx.config.getCacheBusterParam&&e==="get"&&R.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let W=getValuesForElement(n,"hx-request"),oe=getInternalData(n).boosted,P=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,C={boosted:oe,useUrlParams:P,formData:R,parameters:formDataProxy(R),unfilteredFormData:j,unfilteredParameters:formDataProxy(j),headers:H,elt:n,target:u,verb:e,errors:N,withCredentials:o.credentials||W.credentials||htmx.config.withCredentials,timeout:o.timeout||W.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",C))return maybeCall(i),v(),l;if(t=C.path,e=C.verb,H=C.headers,R=formDataFromObject(C.parameters),N=C.errors,P=C.useUrlParams,N&&N.length>0)return triggerEvent(n,"htmx:validation:halted",C),maybeCall(i),v(),l;let se=t.split("#"),Ce=se[0],X=se[1],A=t;if(P&&(A=Ce,!R.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(R),X&&(A+="#"+X))),!verifyPath(n,A,C))return triggerErrorEvent(n,"htmx:invalidPath",C),maybeCall(a),v(),l;if(b.open(e.toUpperCase(),A,!0),b.overrideMimeType("text/html"),b.withCredentials=C.withCredentials,b.timeout=C.timeout,!W.noHeaders){for(let E in H)if(H.hasOwnProperty(E)){let x=H[E];safelySetHeaderValue(b,E,x)}}let T={xhr:b,target:u,requestConfig:C,etc:o,boosted:oe,select:d,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:X}};if(b.onload=function(){try{let E=hierarchyForElt(n);if(T.pathInfo.responsePath=getPathFromResponse(b),c(n,T),T.keepIndicators!==!0&&removeRequestIndicators(F,B),triggerEvent(n,"htmx:afterRequest",T),triggerEvent(n,"htmx:afterOnLoad",T),!bodyContains(n)){let x=null;for(;E.length>0&&x==null;){let L=E.shift();bodyContains(L)&&(x=L)}x&&(triggerEvent(x,"htmx:afterRequest",T),triggerEvent(x,"htmx:afterOnLoad",T))}maybeCall(i)}catch(E){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:E},T)),E}finally{v()}},b.onerror=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:sendError",T),maybeCall(a),v()},b.onabort=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:sendAbort",T),maybeCall(a),v()},b.ontimeout=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:timeout",T),maybeCall(a),v()},!triggerEvent(n,"htmx:beforeRequest",T))return maybeCall(i),v(),l;var F=addRequestIndicatorClasses(n),B=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(E){forEach([b,b.upload],function(x){x.addEventListener(E,function(L){triggerEvent(n,"htmx:xhr:"+E,{lengthComputable:L.lengthComputable,loaded:L.loaded,total:L.total})})})}),triggerEvent(n,"htmx:beforeSend",T);let Ae=P?null:encodeParamsForBody(b,n,R);return b.send(Ae),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 s=t.pathInfo.finalRequestPath,i=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,d=null,u=null;return a?(d="push",u=a):l?(d="replace",u=l):c&&(d="push",u=i||s),u?u==="false"?{}:(u==="true"&&(u=i||s),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:d,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,s=getInternalData(o);s&&s.xhr&&s.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})(),O=Le;(function(){let e;O.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(i,a){Object.hasOwn(o,a)?(Array.isArray(o[a])||(o[a]=[o[a]]),o[a].push(i)):o[a]=i});let s=e.getExpressionVars(r);return Object.keys(o).forEach(function(i){o[i]=Object.hasOwn(s,i)?s[i]:o[i]}),JSON.stringify(o)}})})();var ie="https://typeahead.waow.tech",le="https://public.api.bsky.app",Ie="/xrpc/app.bsky.actor.searchActorsTypeahead",He="/xrpc/app.bsky.actor.getProfiles";var Re="atcr_recent_handles",ce="atcr_recent_profile_cache";var $=class{constructor(t){this.input=t,this.container=t.closest(".sailor-typeahead")||t.parentElement,this.dropdown=null,this.selectedCard=null,this.actors=[],this.currentItems=[],this.mode="hidden",this.focusIndex=-1,this.debounceTimer=null,this.requestSeq=0,this.primaryUnhealthyUntil=0,this.lastPrefetchPrefix="",this.lastPrefetchAt=0,this.createDropdown(),this.bindEvents(),this.input.value.trim().length===0&&this.showRecent()}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="sailor-typeahead-dropdown",this.dropdown.setAttribute("role","listbox"),this.dropdown.style.display="none",this.input.insertAdjacentElement("afterend",this.dropdown)}bindEvents(){this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hide()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&this.selectedCard&&this.clearSelection()})}handleFocus(){this.input.value.trim().length===0&&this.showRecent()}handleInput(){let t=this.input.value.trim();if(t.length===0){this.showRecent();return}if(t.length>=2&&t.length<4){this.hide(),this.schedulePrefetch(t);return}if(t.length>=4){this.scheduleSearch(t);return}this.hide()}schedulePrefetch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runPrefetch(t),150)}scheduleSearch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runSearch(t),150)}async runPrefetch(t){let n=Date.now();if(!(t===this.lastPrefetchPrefix&&n-this.lastPrefetchAt<1e4)&&!(n=this.primaryUnhealthyUntil)try{r=await z(ie,t,1500)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}if(r===null)try{r=await z(le,t,1500)}catch{r=[]}n===this.requestSeq&&(this.actors=r||[],this.focusIndex=-1,this.renderResults())}renderResults(){if(this.mode="results",this.dropdown.innerHTML="",this.currentItems=[],this.actors.length===0){this.hide();return}this.actors.forEach((t,n)=>{this.currentItems.push(t),this.dropdown.appendChild(this.buildActorRow(t,n))}),this.dropdown.style.display="block"}buildActorRow(t,n){let r=document.createElement("div");r.className="sailor-typeahead-item",r.setAttribute("role","option"),r.setAttribute("aria-selected","false"),r.dataset.index=String(n),r.dataset.handle=t.handle;let o=document.createElement("div");if(o.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",l.loading="lazy",o.appendChild(l)}let s=document.createElement("div");s.className="sailor-typeahead-text";let i=t.displayName&&t.displayName!==t.handle;if(i){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,s.appendChild(l)}let a=document.createElement("div");return a.className=i?"sailor-typeahead-handle":"sailor-typeahead-name",a.textContent="@"+t.handle,s.appendChild(a),r.append(o,s),r.addEventListener("mousedown",l=>{l.preventDefault(),this.select(t)}),r}showRecent(){let t=Oe();if(t.length===0){this.hide();return}this.mode="recent",this.focusIndex=-1,this.renderRecent(t),this.enrichRecent(t)}renderRecent(t){let n=_();this.dropdown.innerHTML="",this.currentItems=[];let r=document.createElement("div");r.className="sailor-typeahead-header",r.textContent="Recent accounts",this.dropdown.appendChild(r),t.forEach((o,s)=>{let i=n[o]?.profile||{handle:o};this.currentItems.push(i),this.dropdown.appendChild(this.buildActorRow(i,s))}),this.dropdown.style.display="block"}async enrichRecent(t){let n=_(),r=Date.now(),o=t.filter(a=>{let l=n[a];return!l||r-l.ts>864e5});if(o.length===0)return;let s=await De(o);if(s.length===0)return;let i=_();s.forEach(a=>{i[a.handle]={ts:r,profile:{handle:a.handle,displayName:a.displayName,avatar:a.avatar}}}),ae(i),this.mode==="recent"&&this.renderRecent(t)}hide(){this.mode="hidden",this.focusIndex=-1,this.dropdown.style.display="none"}select(t){if(typeof t=="string"&&(t={handle:t}),this.input.value=t.handle,this.hide(),this.showSelectedCard(t),t.handle){let n=_();n[t.handle]={ts:Date.now(),profile:{handle:t.handle,displayName:t.displayName,avatar:t.avatar}},ae(n)}}showSelectedCard(t){this.clearSelectedCard();let n=document.createElement("div");n.className="sailor-typeahead-selected";let r=document.createElement("div");if(r.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",r.appendChild(l)}let o=document.createElement("div");o.className="sailor-typeahead-text";let s=t.displayName&&t.displayName!==t.handle;if(s){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,o.appendChild(l)}let i=document.createElement("div");i.className=s?"sailor-typeahead-handle":"sailor-typeahead-name",i.textContent="@"+t.handle,o.appendChild(i);let a=document.createElement("button");a.type="button",a.className="sailor-typeahead-clear",a.setAttribute("aria-label","Change account"),a.innerHTML="×",a.addEventListener("click",()=>this.clearSelection()),n.append(r,o,a),this.input.style.display="none",this.input.insertAdjacentElement("beforebegin",n),this.selectedCard=n}clearSelectedCard(){this.selectedCard&&(this.selectedCard.remove(),this.selectedCard=null)}clearSelection(){this.clearSelectedCard(),this.input.style.display="",this.input.value="",this.input.focus(),this.showRecent()}handleKeydown(t){if(this.mode==="hidden")return;let n=this.dropdown.querySelectorAll(".sailor-typeahead-item");n.length!==0&&(t.key==="ArrowDown"?(t.preventDefault(),this.focusIndex=(this.focusIndex+1)%n.length,this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.focusIndex=this.focusIndex<=0?n.length-1:this.focusIndex-1,this.updateFocus(n)):t.key==="Enter"?this.focusIndex>=0&&this.currentItems[this.focusIndex]&&(t.preventDefault(),this.select(this.currentItems[this.focusIndex])):t.key==="Escape"?this.hide():t.key==="Tab"&&this.focusIndex===-1&&n.length>0&&(t.preventDefault(),this.focusIndex=0,this.updateFocus(n)))}updateFocus(t){t.forEach((n,r)=>{let o=r===this.focusIndex;n.classList.toggle("focused",o),n.setAttribute("aria-selected",o?"true":"false"),o&&n.scrollIntoView({block:"nearest"})})}destroy(){this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null)}};async function z(e,t,n){let r=new URL(Ie,e);r.searchParams.set("q",t),r.searchParams.set("limit",String(8));let o=new AbortController,s=setTimeout(()=>o.abort(),n);try{let i=await fetch(r,{signal:o.signal});if(!i.ok)throw new Error("HTTP "+i.status);let a=await i.json();return Array.isArray(a.actors)?a.actors:[]}finally{clearTimeout(s)}}async function De(e){if(e.length===0)return[];let t=new URL(He,le);e.forEach(o=>t.searchParams.append("actors",o));let n=new AbortController,r=setTimeout(()=>n.abort(),3e3);try{let o=await fetch(t,{signal:n.signal});if(!o.ok)return[];let s=await o.json();return Array.isArray(s.profiles)?s.profiles:[]}catch{return[]}finally{clearTimeout(r)}}function _(){try{return JSON.parse(localStorage.getItem(ce)||"{}")}catch{return{}}}function ae(e){try{localStorage.setItem(ce,JSON.stringify(e))}catch{}}function Oe(){try{let e=localStorage.getItem(Re);return e?JSON.parse(e):[]}catch{return[]}}var I=null;function ue(){let e=document.getElementById("handle");e&&(I&&I.input===e||(I&&I.destroy(),I=new $(e)))}document.addEventListener("DOMContentLoaded",ue);document.body.addEventListener("htmx:afterSettle",ue);document.body.addEventListener("htmx:beforeSwap",()=>{I&&!document.contains(I.input)&&(I.destroy(),I=null)});function Y(e){try{return localStorage.getItem(e)}catch{return null}}function K(e,t){try{localStorage.setItem(e,t)}catch{}}function me(){return Y("theme")||"system"}function Me(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function G(){let e=me(),n=Me(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),ke(e)}function ge(e){K("theme",e),G(),qe()}function ke(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e;n.setAttribute("aria-checked",r?"true":"false");let o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function qe(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}document.addEventListener("DOMContentLoaded",()=>{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)})});window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{me()==="system"&&G()});function pe(e,t){if(!e)return;let n=e.querySelector(".nav-search-form"),r=e.querySelector('button[aria-controls="nav-search-form"]');e.classList.toggle("expanded",t),n&&(t?n.removeAttribute("inert"):n.setAttribute("inert","")),r&&r.setAttribute("aria-expanded",t?"true":"false")}function Ne(){let e=document.querySelector(".nav-search-wrapper");if(!e)return;let t=!e.classList.contains("expanded");if(pe(e,t),t){let n=document.getElementById("nav-search-input");n&&n.focus()}}function de(){let e=document.querySelector(".nav-search-wrapper");if(pe(e,!1),e){let t=e.querySelector('[aria-controls="nav-search-form"]');t&&t.focus()}}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{if(n.key==="Escape"&&e.classList.contains("expanded")&&de(),n.key==="/"&&!e.classList.contains("expanded")){let r=n.target.tagName;if(r==="INPUT"||r==="TEXTAREA"||n.target.isContentEditable)return;n.preventDefault(),e.classList.add("expanded"),t.focus()}}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&de()}))});function J(e,t){let n=()=>{if(!t||!document.contains(t))return;let r=t.innerHTML;t.innerHTML=' Copied!',setTimeout(()=>{document.contains(t)&&(t.innerHTML=r)},2e3)};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(e).then(n).catch(r=>{console.error("Clipboard API failed, falling back:",r),fe(e)?n():S("Copy failed \u2014 check browser permissions","error")});return}fe(e)?n():S("Copy failed \u2014 select the text and copy manually","error")}function fe(e){let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.setAttribute("aria-hidden","true"),t.style.position="fixed",t.style.top="0",t.style.left="0",t.style.width="1px",t.style.height="1px",t.style.opacity="0",t.style.pointerEvents="none",document.body.appendChild(t);let n=!1;try{t.focus(),t.select(),t.setSelectionRange(0,e.length),n=document.execCommand&&document.execCommand("copy")}catch{n=!1}return document.body.removeChild(t),!!n}function Pe(e){let t=s=>{let i=(s==null?"":String(s)).trim();return/[",\n\r]/.test(i)?'"'+i.replace(/"/g,'""')+'"':i},n=s=>Array.from(s).map(i=>t(i.textContent)).join(","),r=[],o=e.querySelector("thead tr");return o&&r.push(n(o.querySelectorAll("th,td"))),e.querySelectorAll("tbody tr").forEach(s=>{r.push(n(s.querySelectorAll("td,th")))}),r.join(` +`)}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-copy-csv]");if(t){let r=t.closest("[data-csv-section]"),o=r&&r.querySelector("table");o&&J(Pe(o),t);return}let n=e.target.closest("button[data-cmd]");if(n){J(n.getAttribute("data-cmd"),n);return}})});function Fe(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let s=Math.floor(t/o);if(s>=1)return s===1?`1 ${r} ago`:`${s} ${r}s ago`}return"just now"}function U(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=Fe(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{U(),G(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{ge(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",U);var M=null;function ye(){M===null&&(M=setInterval(U,6e4))}function Be(){M!==null&&(clearInterval(M),M=null)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?Be():(U(),ye())});ye();async function _e(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();Ue(e,t,n,o.tags)}else if(r.ok)Ee(n);else{let o=await r.text();S(`Failed to delete manifest: ${o||r.status}`,"error")}}catch(r){console.error("Error deleting manifest:",r),S(`Error deleting manifest: ${r.message}`,"error")}}function Ue(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),s=document.getElementById("manifest-delete-tags"),i=document.getElementById("confirm-manifest-delete-btn");s.innerHTML="",r.forEach(a=>{let l=document.createElement("li");l.textContent=a,s.appendChild(l)}),i.onclick=()=>Ve(e,t,n),Z(o)}function Q(){k(document.getElementById("manifest-delete-modal"))}async function Ve(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let s=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(s.ok)Q(),Ee(n),location.reload();else{let i=await s.text();S(`Failed to delete manifest: ${i||s.status}`,"error"),r.disabled=!1,r.textContent=o}}catch(s){console.error("Error deleting manifest:",s),S(`Error deleting manifest: ${s.message}`,"error"),r.disabled=!1,r.textContent=o}}async function je(e){let t=document.getElementById("confirm-untagged-delete-btn"),n=t.textContent;try{t.disabled=!0,t.textContent="Deleting...";let r=await fetch("/api/manifests/untagged",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e})}),o=await r.json();r.ok?(k(document.getElementById("untagged-delete-modal")),S(`Deleted ${o.deleted} untagged manifest(s)`,"success"),o.deleted>0&&location.reload(),t.disabled=!1,t.textContent=n):(S(`Failed to delete untagged manifests: ${o.error||"Unknown error"}`,"error"),t.disabled=!1,t.textContent=n)}catch(r){console.error("Error deleting untagged manifests:",r),S(`Error: ${r.message}`,"error"),t.disabled=!1,t.textContent=n}}function Ee(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&Q()})});var q=new WeakMap;function Z(e,t){if(e&&(q.set(e,t||document.activeElement),typeof e.showModal=="function")){e.open&&(e.open=!1);try{e.showModal()}catch{}}}function k(e,{remove:t=!1}={}){if(!e)return;let n=q.get(e);if(q.delete(e),typeof e.close=="function"&&e.open)try{e.close()}catch{}t&&e.remove(),ve(n)}function ve(e){e&&typeof e.focus=="function"&&document.contains(e)&&e.focus()}document.addEventListener("close",e=>{let t=e.target;if(!(t instanceof HTMLDialogElement))return;let n=q.get(t);q.delete(t),ve(n)},!0);document.body.addEventListener("htmx:afterSettle",()=>{document.querySelectorAll("dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])").forEach(t=>{t.dataset.modalPromoted="1",Z(t)})});document.addEventListener("change",e=>{let t=e.target.closest("select[data-diff-url]");if(!t)return;let n=t.dataset.diffUrl;n&&(window.location.href=n.replace("__VALUE__",encodeURIComponent(t.value)))});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("pull-cmd-container");if(!e)return;let t=e.dataset.registryUrl,n=e.dataset.ownerHandle,r=e.dataset.repoName,o=e.dataset.tag||"latest",s=e.dataset.isLoggedIn==="true";function i(l){let d=(l==="none"?"":l+" pull ")+t+"/"+n+"/"+r+":"+o,u=document.getElementById("pull-cmd-display");if(!u)return;let f=u.querySelector("code");f&&(f.textContent=d);let m=u.querySelector("[data-cmd]");m&&(m.dataset.cmd=d),s&&window.htmx?window.htmx.ajax("POST","/api/profile/oci-client",{values:{oci_client:l},swap:"none"}):s||K("oci-client",l)}if(!s){let l=Y("oci-client");if(l){let c=document.getElementById("oci-client-switcher");c&&(c.value=l,i(l))}}let a=document.getElementById("oci-client-switcher");a&&a.addEventListener("change",()=>i(a.value))});document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll(".platform-tab[data-platform]");e.length&&e.forEach(t=>{t.addEventListener("click",()=>{e.forEach(r=>{let o=r===t;r.classList.toggle("btn-primary",o),r.classList.toggle("btn-ghost",!o),r.setAttribute("aria-selected",o?"true":"false"),r.setAttribute("tabindex",o?"0":"-1")}),document.querySelectorAll(".platform-content").forEach(r=>{r.classList.add("hidden"),r.setAttribute("hidden","")});let n=document.getElementById(t.dataset.platform+"-content");n&&(n.classList.remove("hidden"),n.removeAttribute("hidden"),t.focus())}),t.addEventListener("keydown",n=>{if(n.key!=="ArrowLeft"&&n.key!=="ArrowRight")return;n.preventDefault();let r=Array.from(e),o=r.indexOf(t);(n.key==="ArrowRight"?r[(o+1)%r.length]:r[(o-1+r.length)%r.length]).click()})})});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form");e&&e.addEventListener("submit",()=>{let t=e.querySelector('button[type="submit"]');!t||t.disabled||(t.disabled=!0,t.innerHTML=' Navigating…')})});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t&&typeof t=="string"&&t.length>0){try{let n="atcr_recent_handles",r=Y(n),o=[];try{o=JSON.parse(r||"[]")}catch{o=[]}Array.isArray(o)||(o=[]),o=o.filter(s=>s!==t),o.unshift(t),o=o.slice(0,5),K(n,JSON.stringify(o))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function he(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=e.querySelectorAll(".carousel-item");if(r.length===0||!r[0])return;let o=null,s=5e3,i=window.matchMedia("(prefers-reduced-motion: reduce)"),a=()=>i.matches?"auto":"smooth",l=0,c=0;function d(){if(!r[0])return;let y=parseFloat(getComputedStyle(e).gap)||24;l=r[0].offsetWidth+y}d(),window.addEventListener("resize",()=>{cancelAnimationFrame(c),c=requestAnimationFrame(d)}),document.body.addEventListener("htmx:afterSettle",y=>{y.target&&y.target.contains&&y.target.contains(e)&&d()});function u(){let y=e.scrollWidth-e.clientWidth;e.scrollLeft>=y-10?e.scrollTo({left:0,behavior:a()}):e.scrollBy({left:l,behavior:a()})}function f(){e.scrollLeft<=10?e.scrollTo({left:e.scrollWidth,behavior:a()}):e.scrollBy({left:-l,behavior:a()})}function m(){o||document.visibilityState!=="hidden"&&(e.scrollWidth<=e.clientWidth+10||i.matches||(o=setInterval(u,s)))}function h(){o&&(clearInterval(o),o=null)}t&&t.addEventListener("click",()=>{h(),f(),m()}),n&&n.addEventListener("click",()=>{h(),u(),m()});let g=document.getElementById("carousel-pause"),p=!1;if(g){let y=g.querySelector(".carousel-pause-icon"),w=g.querySelector(".carousel-play-icon");g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),g.addEventListener("click",()=>{p=!p,p?(h(),g.setAttribute("aria-pressed","true"),g.setAttribute("aria-label","Resume carousel auto-advance"),y&&y.classList.add("hidden"),w&&w.classList.remove("hidden")):(g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),y&&y.classList.remove("hidden"),w&&w.classList.add("hidden"),m())})}e.addEventListener("mouseenter",h),e.addEventListener("mouseleave",()=>{p||m()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?h():p||m()}),m()}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(he,{timeout:2e3}):setTimeout(he,100)});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,s=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";S(s,"error")});document.body.addEventListener("htmx:sendError",e=>{let t=e.detail&&e.detail.elt;t&&t.closest&&t.closest("[data-suppress-htmx-toast]")||S("Network error \u2014 check your connection","error")});document.body.addEventListener("toast",e=>{let t=e&&e.detail||{},n=t.message||t.msg||"";if(!n)return;let r=t.type||"info";S(n,r)});var We=4,Xe=1500;function be(){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)}document.addEventListener("DOMContentLoaded",be);function S(e,t){let n=be(),r=(t||"info")+"|"+e,o=Date.now(),s=n.querySelector(`[data-toast-key="${$e(r)}"]`);if(s&&o-Number(s.dataset.toastAt)We;)n.firstElementChild.remove();we(l)}function we(e){e._dismissTimer=setTimeout(()=>{e.style.opacity="0",e._removeTimer=setTimeout(()=>e.remove(),300)},3e3)}function ze(e){clearTimeout(e._dismissTimer),clearTimeout(e._removeTimer),e.style.opacity="",e.dataset.toastAt=String(Date.now()),we(e)}function $e(e){return window.CSS&&CSS.escape?CSS.escape(e):String(e).replace(/[^a-zA-Z0-9_-]/g,t=>"\\"+t)}async function Je(e){try{let t=await fetch(`/api/webhooks/${e}/test`,{method:"POST",credentials:"include"}),n=await t.text();n.includes('class="success"')||t.ok&&!n.includes('class="error"')?S("Test webhook delivered successfully!","success"):S("Test delivery failed \u2014 check the webhook URL","error")}catch{S("Failed to reach server","error")}}(function(){let t={"switch-repo-tab":s=>window.switchRepoTab&&window.switchRepoTab(s.dataset.tab),"switch-editor-tab":s=>window.switchEditorTab&&window.switchEditorTab(s.dataset.tab),"insert-md":s=>window.insertMd&&window.insertMd(s.dataset.mdType),"toggle-editor":s=>window.toggleOverviewEditor&&window.toggleOverviewEditor(s.dataset.show==="true"),"show-modal":s=>Z(document.getElementById(s.dataset.modalId),s),"close-dialog":s=>k(s.closest("dialog")),"remove-closest-dialog":s=>k(s.closest("dialog"),{remove:!0}),"close-manifest-delete-modal":()=>window.closeManifestDeleteModal&&window.closeManifestDeleteModal(),"save-overview":()=>window.saveOverview&&window.saveOverview(),"delete-manifest":s=>window.deleteManifest&&window.deleteManifest(s.dataset.repo,s.dataset.digest,s.dataset.manifestId||""),"delete-untagged":s=>window.deleteUntaggedManifests&&window.deleteUntaggedManifests(s.dataset.repo),copy:s=>window.copyToClipboard&&window.copyToClipboard(s.dataset.copy,s),"toggle-search":()=>window.toggleSearch&&window.toggleSearch(),"switch-settings-tab":s=>window.switchSettingsTab&&window.switchSettingsTab(s.dataset.tab),"test-webhook":s=>window.testWebhook&&window.testWebhook(s.dataset.webhookId),"diff-to":(s,i)=>window.diffToTag&&window.diffToTag(i,s),"modal-backdrop-close":(s,i)=>{i.target===s&&k(s,{remove:!0})}},n={"sort-tags":s=>window.sortTags&&window.sortTags(s.value),"submit-form":s=>s.form&&s.form.requestSubmit()},r={"filter-tags":s=>window.filterTags&&window.filterTags(s.value)};function o(s,i){let a=i.target.closest("[data-action]");if(!a)return;let l=s[a.dataset.action];l&&l(a,i)}document.addEventListener("click",s=>o(t,s)),document.addEventListener("change",s=>o(n,s)),document.addEventListener("input",s=>o(r,s))})();window.setTheme=ge;window.toggleSearch=Ne;window.copyToClipboard=J;window.deleteManifest=_e;window.deleteUntaggedManifests=je;window.closeManifestDeleteModal=Q;window.showToast=S;window.testWebhook=Je;function Ye(){let e=document.getElementById("md-editor");if(!e)return;let t=e.dataset.ownerDid,n=e.dataset.repository;window.toggleOverviewEditor=function(r){document.getElementById("overview-view").classList.toggle("hidden",r),document.getElementById("overview-edit").classList.toggle("hidden",!r),r&&e.focus()},window.switchEditorTab=function(r){if(document.querySelectorAll(".editor-panel").forEach(o=>o.classList.add("hidden")),document.getElementById(r==="write"?"editor-write":"editor-preview").classList.remove("hidden"),document.querySelectorAll(".editor-tab").forEach(o=>{let s=o.dataset.tab===r;o.classList.toggle("border-primary",s),o.classList.toggle("text-primary",s),o.classList.toggle("border-transparent",!s),o.classList.toggle("text-base-content/60",!s)}),r==="preview"){let o=e.value,s=document.getElementById("preview-content");if(!o.trim()){s.innerHTML='

Nothing to preview

';return}s.innerHTML='

Rendering preview…

';let i=new FormData;i.append("markdown",o),fetch("/api/repo-page/preview",{method:"POST",body:i}).then(a=>{if(!a.ok)throw new Error("HTTP "+a.status);return a.text()}).then(a=>{s.innerHTML=a}).catch(()=>{s.innerHTML='

Preview failed. Check your connection and try again.

'})}},window.insertMd=function(r){let o=e.selectionStart,s=e.selectionEnd,i=e.value.substring(o,s),a=e.value.substring(0,o),l=e.value.substring(s),c,d,u;switch(r){case"heading":c="## "+(i||"Heading"),d=o+3,u=o+c.length;break;case"bold":c="**"+(i||"bold text")+"**",d=o+2,u=o+c.length-2;break;case"italic":c="_"+(i||"italic text")+"_",d=o+1,u=o+c.length-1;break;case"link":c="["+(i||"link text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"image":c="!["+(i||"alt text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"ul":c="- "+(i||"list item"),d=o+2,u=o+c.length;break;case"ol":c="1. "+(i||"list item"),d=o+3,u=o+c.length;break;case"code":i&&i.indexOf(` +`)!==-1?(c="```\n"+i+"\n```",d=o+4,u=o+4+i.length):(c="`"+(i||"code")+"`",d=o+1,u=o+c.length-1);break;default:return}e.value=a+c+l,e.focus(),e.selectionStart=d,e.selectionEnd=u},window.saveOverview=function(){let r=document.getElementById("save-overview-btn");r.classList.add("btn-disabled"),r.innerHTML=' Saving...';let o=new FormData;o.append("did",t),o.append("repository",n),o.append("description",e.value),fetch("/api/repo-page",{method:"POST",body:o,headers:{"HX-Request":"true"}}).then(s=>s.ok?s.text():s.text().then(i=>{throw new Error(i)})).then(s=>{document.getElementById("overview-rendered").innerHTML=s,window.toggleOverviewEditor(!1),typeof window.showToast=="function"&&window.showToast("Overview saved","success")}).catch(s=>{typeof window.showToast=="function"&&window.showToast(s.message||"Failed to save","error")}).finally(()=>{r.classList.remove("btn-disabled"),r.innerHTML="Save"})},e.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="s"&&(r.preventDefault(),window.saveOverview())})}window.sortTags=function(e){let t=document.getElementById("tags-list");if(!t)return;let n=Array.from(t.querySelectorAll(".artifact-entry"));n.sort((r,o)=>{switch(e){case"oldest":return parseInt(r.dataset.created)-parseInt(o.dataset.created);case"az":return r.dataset.tag.localeCompare(o.dataset.tag);case"za":return o.dataset.tag.localeCompare(r.dataset.tag);default:return parseInt(o.dataset.created)-parseInt(r.dataset.created)}}),n.forEach(r=>t.appendChild(r))};var D=0;window.filterTags=function(e){D&&cancelAnimationFrame(D),D=requestAnimationFrame(()=>{D=0;let t=e.toLowerCase();document.querySelectorAll("#tags-list .artifact-entry").forEach(n=>{n.style.display=!t||n.dataset.tag.toLowerCase().includes(t)?"":"none"})})};document.body.addEventListener("htmx:beforeSwap",()=>{D&&(cancelAnimationFrame(D),D=0)});function Ke(){if(!document.getElementById("tag-content"))return;let e=["overview","layers","vulns","sbom","artifacts"],t={};function n(i,a){if(t[i]==="loading"||t[i]==="loaded")return;t[i]="loading";let l=document.getElementById(i);if(!l){delete t[i];return}let c=new AbortController,d=setTimeout(()=>c.abort(),1e4);fetch(a,{signal:c.signal}).then(u=>{if(!u.ok)throw new Error("HTTP "+u.status);return u.text()}).then(u=>{t[i]="loaded",document.contains(l)&&(l.innerHTML=u,l.querySelectorAll("script").forEach(f=>{let m=document.createElement("script");m.textContent=f.textContent,f.parentNode.replaceChild(m,f)}),typeof window.htmx<"u"&&window.htmx.process(l))}).catch(u=>{if(delete t[i],!document.contains(l))return;let m=u&&u.name==="AbortError"?"This section took too long to load.":"Couldn't load this section.";l.innerHTML='

'+m+'

'}).finally(()=>clearTimeout(d))}document.body.addEventListener("click",i=>{let a=i.target.closest("[data-retry-section]");if(!a)return;let l=a.getAttribute("data-retry-section"),d={"artifacts-content":o,"layers-content":()=>r("layers"),"vulns-content":()=>r("vulns"),"sbom-content":()=>r("sbom")}[l];if(d){let u=d();u&&n(l,u)}});function r(i){let a=document.getElementById("tag-content");if(!a||!a.dataset)return null;let l=a.dataset.digest,c=a.dataset.owner,d=a.dataset.repo;return!l||!c||!d?null:"/api/digest-content/"+c+"/"+d+"?digest="+encodeURIComponent(l)+"§ion="+i}function o(){let i=document.getElementById("tag-content");if(!i||!i.dataset)return null;let a=i.dataset.owner,l=i.dataset.repo;return!a||!l?null:"/api/repo-tags/"+a+"/"+l}window.diffToTag=function(i,a){i.preventDefault();let l=a.dataset.diffTo,c=document.getElementById("tag-content"),d=document.getElementById("tag-selector");if(!c||!d||!l)return;let u=c.dataset.digest,f=d.value;!u||l===f||(window.location.href="/diff/"+c.dataset.owner+"/"+c.dataset.repo+"?from="+encodeURIComponent(u)+"&to="+encodeURIComponent(l))},window.switchRepoTab=function(i){window._activeRepoTab=i;let a=document.getElementById("tag-content");if(!a)return;a.querySelectorAll(".repo-panel").forEach(d=>d.classList.add("hidden"));let l=document.getElementById("tab-"+i);l&&l.classList.remove("hidden"),a.querySelectorAll(".repo-tab").forEach(d=>{let u=d.dataset.tab===i;d.classList.toggle("border-primary",u),d.classList.toggle("text-primary",u),d.classList.toggle("border-transparent",!u),d.classList.toggle("text-base-content/60",!u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")});let c=new URL(window.location);if(c.hash=i,history.replaceState(null,"",c.toString()),i==="artifacts"){let d=o();d&&n("artifacts-content",d)}if(i==="layers"){let d=r("layers");d&&n("layers-content",d)}if(i==="vulns"){let d=r("vulns");d&&n("vulns-content",d)}if(i==="sbom"){let d=r("sbom");d&&n("sbom-content",d)}};function s(){t={},[["artifacts-tab-btn","artifacts-content",o],["layers-tab-btn","layers-content",()=>r("layers")],["vulns-tab-btn","vulns-content",()=>r("vulns")],["sbom-tab-btn","sbom-content",()=>r("sbom")]].forEach(([c,d,u])=>{let f=document.getElementById(c);f&&f.addEventListener("mouseenter",()=>{let m=u();m&&n(d,m)},{once:!0})});let a=document.querySelector('[role="tablist"][aria-label="Repository sections"]');a&&!a.dataset.keyboardBound&&(a.dataset.keyboardBound="1",a.addEventListener("keydown",c=>{let d=Array.from(a.querySelectorAll(".repo-tab")),u=d.indexOf(document.activeElement);if(u===-1)return;let f=-1;switch(c.key){case"ArrowRight":f=(u+1)%d.length;break;case"ArrowLeft":f=(u-1+d.length)%d.length;break;case"Home":f=0;break;case"End":f=d.length-1;break;case"Enter":case" ":c.preventDefault(),window.switchRepoTab(d[u].dataset.tab);return;default:return}c.preventDefault(),d[f].focus()}));let l=window._activeRepoTab||window.location.hash.replace("#","")||"overview";e.indexOf(l)===-1&&(l="overview"),window.switchRepoTab(l)}s(),document.addEventListener("keydown",i=>{if(i.target.tagName==="INPUT"||i.target.tagName==="TEXTAREA"||i.target.tagName==="SELECT"||i.target.isContentEditable||i.ctrlKey||i.metaKey||i.altKey)return;let l={o:"overview",l:"layers",v:"vulns",s:"sbom",a:"artifacts"}[i.key.toLowerCase()];l&&e.indexOf(l)!==-1&&window.switchRepoTab(l)}),document.body.addEventListener("htmx:afterSettle",i=>{i.detail.target&&i.detail.target.id==="tag-content"&&s()})}document.addEventListener("DOMContentLoaded",()=>{Ye(),Ke()});function Ge(){let e=Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')),t=Array.from(document.querySelectorAll(".settings-tab-mobile"));if(!e.length&&!t.length)return;function n(s,i){let a=i==="vertical"?"ArrowUp":"ArrowLeft",l=i==="vertical"?"ArrowDown":"ArrowRight";s.forEach(c=>{c.addEventListener("keydown",d=>{let u=s.indexOf(d.currentTarget);if(u===-1)return;let f=null;d.key===a?f=s[(u-1+s.length)%s.length]:d.key===l?f=s[(u+1)%s.length]:d.key==="Home"?f=s[0]:d.key==="End"&&(f=s[s.length-1]),f&&(d.preventDefault(),f.focus(),f.click())})})}n(e,"vertical"),n(t,"horizontal");function r(){let s=t.find(i=>i.getAttribute("aria-selected")==="true");s&&s.scrollIntoView({inline:"center",block:"nearest"})}r();function o(s){e.forEach(i=>{let a=i.parentElement.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.parentElement.classList.toggle("menu-active",a)}),t.forEach(i=>{let a=i.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.classList.toggle("btn-secondary",a),i.classList.toggle("btn-ghost",!a)}),r()}[...e,...t].forEach(s=>{s.addEventListener("click",()=>o(s.dataset.tab||s.parentElement.dataset.tab))}),document.body.addEventListener("htmx:historyRestore",()=>{let s=location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/);s&&o(s[1])})}function Qe(){document.addEventListener("click",function(n){let r=n.target.closest("#delete-account-btn");r&&t(r)});function e(n){let r=document.createElement("div");return r.textContent=n,r.innerHTML}function t(n){let r=n.dataset.clientShortName||"this account",s="DELETE "+(n.dataset.profileHandle||""),i=document.getElementById("delete-pds-records").checked,a=document.createElement("div");a.className="modal modal-open",a.innerHTML=` - `,document.body.appendChild(l);let a=document.getElementById("confirm-delete-input"),c=document.getElementById("confirm-delete"),d=document.getElementById("cancel-delete");setTimeout(()=>a.focus(),100),a.addEventListener("input",function(){c.disabled=this.value!==r}),a.addEventListener("keydown",function(m){m.key==="Enter"&&this.value===r&&f()}),d.addEventListener("click",()=>l.remove()),document.getElementById("modal-backdrop").addEventListener("click",()=>l.remove());function u(m){m.key==="Escape"&&(l.remove(),document.removeEventListener("keydown",u))}document.addEventListener("keydown",u),c.addEventListener("click",f);async function f(){let m=document.getElementById("delete-pds-records").checked;c.disabled=!0,c.innerHTML=' Deleting...',d.disabled=!0;try{let h=await fetch("/api/account",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({delete_pds_records:m,confirmation:r})}),y=await h.json();if(h.ok&&y.success)l.querySelector(".modal-box").innerHTML=` + `,document.body.appendChild(a);let l=document.getElementById("confirm-delete-input"),c=document.getElementById("confirm-delete"),d=document.getElementById("cancel-delete");setTimeout(()=>l.focus(),100),l.addEventListener("input",function(){c.disabled=this.value!==s}),l.addEventListener("keydown",function(m){m.key==="Enter"&&this.value===s&&f()}),d.addEventListener("click",()=>a.remove()),document.getElementById("modal-backdrop").addEventListener("click",()=>a.remove());function u(m){m.key==="Escape"&&(a.remove(),document.removeEventListener("keydown",u))}document.addEventListener("keydown",u),c.addEventListener("click",f);async function f(){let m=document.getElementById("delete-pds-records").checked;c.disabled=!0,c.innerHTML=' Deleting...',d.disabled=!0;try{let h=await fetch("/api/account",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({delete_pds_records:m,confirmation:s})}),g=await h.json();if(h.ok&&g.success)a.querySelector(".modal-box").innerHTML=`

Account Deleted @@ -48,7 +48,7 @@ var xe=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:n

Your account has been successfully deleted.

Redirecting to home page...

- `,setTimeout(()=>{window.location.href="/?deleted=true"},2e3);else{let p=y.errors||["An unknown error occurred"];l.querySelector(".modal-box").innerHTML=` + `,setTimeout(()=>{window.location.href="/?deleted=true"},2e3);else{let p=g.errors||["An unknown error occurred"];a.querySelector(".modal-box").innerHTML=`

Deletion Failed @@ -56,21 +56,21 @@ var xe=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:n

There were errors during account deletion:

    - ${p.map(w=>"
  • "+o(w)+"
  • ").join("")} + ${p.map(y=>"
  • "+e(y)+"
  • ").join("")}
- `,l.querySelector("[data-dismiss-modal]").addEventListener("click",()=>l.remove())}}catch(h){console.error("Delete account error:",h),l.querySelector(".modal-box").innerHTML=` + `,a.querySelector("[data-dismiss-modal]").addEventListener("click",()=>a.remove())}}catch(h){console.error("Delete account error:",h),a.querySelector(".modal-box").innerHTML=`

Error

-

Failed to delete account: ${o(h.message)}

+

Failed to delete account: ${e(h.message)}

- `,l.querySelector("[data-dismiss-modal]").addEventListener("click",()=>l.remove())}}}}document.addEventListener("DOMContentLoaded",()=>{ze(),$e()});var G="showEmptyLayers";function Je(e,t,n){let r=0;for(let o=t;o('+s+' layers, click to expand)'+a+"",c.addEventListener("click",()=>{c.remove();for(let d=o;d{n.style.display=t?"":"none"})}function Ee(e){let t=e||document;(t.querySelectorAll?t.querySelectorAll(".layers-table:not([data-layers-processed])"):[]).forEach(r=>{r.setAttribute("data-layers-processed","1"),Ke(r),ye(r)})}function Ge(e){localStorage.setItem(G,e),document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),document.querySelectorAll(".layers-table").forEach(ye)}document.addEventListener("DOMContentLoaded",()=>{let e=localStorage.getItem(G)==="true";document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),Ee()});document.addEventListener("change",e=>{e.target.matches("[data-toggle-empty-layers]")&&Ge(e.target.checked)});document.body.addEventListener("htmx:afterSettle",e=>{e.target&&e.target.querySelectorAll&&Ee(e.target)});window.htmx=D;D.config.methodsThatUseUrlParams=["get"]; + `,a.querySelector("[data-dismiss-modal]").addEventListener("click",()=>a.remove())}}}}document.addEventListener("DOMContentLoaded",()=>{Ge(),Qe()});var ee="showEmptyLayers";function Ze(e,t,n){let r=0;for(let o=t;o('+s+' layers, click to expand)'+l+"",c.addEventListener("click",()=>{c.remove();for(let d=o;d{n.style.display=t?"":"none"})}function Te(e){let t=e||document;(t.querySelectorAll?t.querySelectorAll(".layers-table:not([data-layers-processed])"):[]).forEach(r=>{r.setAttribute("data-layers-processed","1"),tt(r),xe(r)})}function nt(e){localStorage.setItem(ee,e),document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),document.querySelectorAll(".layers-table").forEach(xe)}document.addEventListener("DOMContentLoaded",()=>{let e=localStorage.getItem(ee)==="true";document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),Te()});document.addEventListener("change",e=>{e.target.matches("[data-toggle-empty-layers]")&&nt(e.target.checked)});document.body.addEventListener("htmx:afterSettle",e=>{e.target&&e.target.querySelectorAll&&Te(e.target)});window.htmx=O;O.config.methodsThatUseUrlParams=["get"]; diff --git a/pkg/appview/public/sitemap-static.xml b/pkg/appview/public/sitemap-static.xml index 56c41b5..116e4fd 100644 --- a/pkg/appview/public/sitemap-static.xml +++ b/pkg/appview/public/sitemap-static.xml @@ -2,26 +2,31 @@ https://atcr.io/ + 2026-04-21 daily 1.0 https://atcr.io/search + 2026-04-21 daily 0.8 https://atcr.io/install + 2026-04-21 monthly 0.9 https://atcr.io/privacy + 2026-04-21 yearly 0.3 https://atcr.io/terms + 2026-04-21 yearly 0.3 diff --git a/pkg/appview/readme/fetcher_test.go b/pkg/appview/readme/fetcher_test.go index f9d3ef2..20de18b 100644 --- a/pkg/appview/readme/fetcher_test.go +++ b/pkg/appview/readme/fetcher_test.go @@ -296,6 +296,110 @@ func TestFetcher_RenderMarkdown(t *testing.T) { } } +// TestRenderMarkdown_XSSRegression verifies that XSS payloads cannot survive +// the goldmark→bluemonday pipeline. Goldmark (without WithUnsafe) replaces raw +// HTML with ""; bluemonday then strips that comment and +// any event-handler attributes or dangerous protocols. +func TestRenderMarkdown_XSSRegression(t *testing.T) { + fetcher := NewFetcher() + + tests := []struct { + name string + input string + wantAbsent []string // must NOT appear in output + wantPresent []string // MUST appear in output (safe rendered form) + }{ + { + name: "inline script tag", + input: "", + wantAbsent: []string{""}, + }, + { + name: "script tag in fenced code block is escaped, not executed", + input: "```\n\n```", + // goldmark HTML-escapes content inside code blocks + wantAbsent: []string{"">`, + wantAbsent: []string{"data:text/html", "alert("}, + }, + { + name: "form action exfiltration", + input: `
`, + wantAbsent: []string{"click me

`, + wantAbsent: []string{"onclick", "alert("}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := fetcher.RenderMarkdown([]byte(tt.input)) + if err != nil { + t.Fatalf("RenderMarkdown() unexpected error: %v", err) + } + for _, bad := range tt.wantAbsent { + if strings.Contains(result, bad) { + t.Errorf("output contains dangerous string %q\nfull output: %s", bad, result) + } + } + for _, good := range tt.wantPresent { + if !strings.Contains(result, good) { + t.Errorf("output missing expected string %q\nfull output: %s", good, result) + } + } + }) + } +} + func containsSubstring(s, substr string) bool { return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr))) } diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 5a7a133..41bafbb 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -45,6 +45,7 @@ type UIDependencies struct { BillingManager *billing.Manager // Stripe billing manager (nil if not configured) WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured) ClaudeAPIKey string // Anthropic API key for AI advisor (empty = disabled) + SourceURL string // Source code URL for the footer "Source" link } // RegisterUIRoutes registers all web UI and API routes on the provided router @@ -80,6 +81,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { ClientName: deps.ClientName, ClientShortName: deps.ClientShortName, AIAdvisorEnabled: deps.ClaudeAPIKey != "", + SourceURL: deps.SourceURL, } // OAuth login routes (public) @@ -178,7 +180,14 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { router.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database)) - r.Get("/settings", (&uihandlers.SettingsHandler{BaseUIHandler: base}).ServeHTTP) + settings := &uihandlers.SettingsHandler{BaseUIHandler: base} + r.Get("/settings", settings.ServeHTTP) + r.Get("/settings/user", settings.ServeTab("user")) + r.Get("/settings/storage", settings.ServeTab("storage")) + r.Get("/settings/billing", settings.ServeTab("billing")) + r.Get("/settings/devices", settings.ServeTab("devices")) + r.Get("/settings/webhooks", settings.ServeTab("webhooks")) + r.Get("/settings/advanced", settings.ServeTab("advanced")) r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP) r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP) diff --git a/pkg/appview/server.go b/pkg/appview/server.go index e58c621..cd5d791 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -334,6 +334,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, BillingManager: s.BillingManager, WebhookDispatcher: s.WebhookDispatcher, ClaudeAPIKey: cfg.AI.APIKey, + SourceURL: cfg.UI.SourceURL, LegalConfig: routes.LegalConfig{ CompanyName: cfg.Legal.CompanyName, Jurisdiction: cfg.Legal.Jurisdiction, diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css index f7a1b43..d683a6a 100644 --- a/pkg/appview/src/css/main.css +++ b/pkg/appview/src/css/main.css @@ -175,6 +175,12 @@ even if values currently coincide. Used by .text-star/.fill-star/etc. */ --color-star: oklch(82% 0.189 84.429); + /* Helm brand color (official Helm blue #0F1689). Two variants so the + light-mode value stays legible on a near-white surface and the + dark-mode value stays legible on Deep Ocean. */ + --color-helm-light: oklch(31% 0.181 267.5); + --color-helm-dark: oklch(64.6% 0.19 273.2); + /* Vulnerability severity scale. Held constant across themes on purpose: CVE severity is a product-semantic signal that needs to read the same way regardless of surface. Content-pair colors come from the same hue @@ -391,10 +397,11 @@ 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 only on coarse-pointer devices so - pointer-primary layouts stay dense. + 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) { +@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) { @@ -508,9 +515,33 @@ /* `min-w-0` + `flex-1` let the code shrink below its intrinsic width so `truncate` can actually produce an ellipsis inside a flex container. - Without them, long commands overflow silently. */ + Without them, long commands overflow silently. `pr-10` reserves room + for the absolutely-positioned copy button so the ellipsis doesn't + sit under it. */ .cmd code { - @apply font-mono text-sm truncate min-w-0 flex-1; + @apply font-mono text-sm truncate min-w-0 flex-1 pr-10; + } + + /* Copy button visibility: + - Touch / coarse-pointer devices (tap can't produce :hover and + rarely produces :focus): always visible at sm+ widths so users + can find the control. + - Hover-capable devices (desktop): hidden until the .cmd group is + hovered or the button itself focused, keeping the command line + visually tidy while power users still get the affordance. + Mobile ( +// open state. Without this, SR announcements lag behind actual disclosure. +document.addEventListener('DOMContentLoaded', () => { + 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); + }); +}); + // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (getThemePreference() === 'system') { @@ -88,7 +110,14 @@ function toggleSearch() { } function closeSearch() { - setSearchExpanded(document.querySelector('.nav-search-wrapper'), false); + const wrapper = document.querySelector('.nav-search-wrapper'); + setSearchExpanded(wrapper, false); + // Return focus to the toggle button so keyboard users don't get dropped + // back at the top of the page when the search form collapses. + if (wrapper) { + const toggle = wrapper.querySelector('[aria-controls="nav-search-form"]'); + if (toggle) toggle.focus(); + } } // Close search on Escape key and click outside @@ -127,10 +156,12 @@ document.addEventListener('DOMContentLoaded', () => { // dispatcher or direct callers — no implicit global `event` fallback. function copyToClipboard(text, btn) { const onSuccess = () => { - if (!btn) return; + if (!btn || !document.contains(btn)) return; const originalHTML = btn.innerHTML; btn.innerHTML = ' Copied!'; - setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); + setTimeout(() => { + if (document.contains(btn)) btn.innerHTML = originalHTML; + }, 2000); }; if (navigator.clipboard && window.isSecureContext) { @@ -184,10 +215,13 @@ function legacyCopy(text) { return !!ok; } -// Serialize a (thead + tbody) as CSV (RFC 4180 quoting) +// Serialize a
(thead + tbody) as CSV (RFC 4180 quoting). +// Preserves embedded newlines — RFC 4180 allows them inside quoted fields, +// and Excel/Sheets decode them back into line breaks. Collapsing them to +// spaces would silently lose structure in multi-line SBOM/vuln cells. function tableToCSV(table) { const escape = (s) => { - const v = (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim(); + const v = (s == null ? '' : String(s)).trim(); return /[",\n\r]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }; const rowToCsv = (cells) => Array.from(cells).map((c) => escape(c.textContent)).join(','); @@ -559,13 +593,13 @@ document.addEventListener('DOMContentLoaded', () => { if (isLoggedIn && window.htmx) { window.htmx.ajax('POST', '/api/profile/oci-client', { values: { oci_client: client }, swap: 'none' }); } else if (!isLoggedIn) { - localStorage.setItem('oci-client', client); + lsSet('oci-client', client); } } // Restore preference for anonymous users. if (!isLoggedIn) { - const saved = localStorage.getItem('oci-client'); + const saved = lsGet('oci-client'); if (saved) { const sel = document.getElementById('oci-client-switcher'); if (sel) { @@ -591,10 +625,30 @@ document.addEventListener('DOMContentLoaded', () => { const active = t === tab; t.classList.toggle('btn-primary', active); t.classList.toggle('btn-ghost', !active); + t.setAttribute('aria-selected', active ? 'true' : 'false'); + t.setAttribute('tabindex', active ? '0' : '-1'); + }); + document.querySelectorAll('.platform-content').forEach(p => { + p.classList.add('hidden'); + p.setAttribute('hidden', ''); }); - document.querySelectorAll('.platform-content').forEach(p => p.classList.add('hidden')); const panel = document.getElementById(tab.dataset.platform + '-content'); - if (panel) panel.classList.remove('hidden'); + if (panel) { + panel.classList.remove('hidden'); + panel.removeAttribute('hidden'); + tab.focus(); + } + }); + // Arrow-key navigation across tabs within the tablist. + tab.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + const tabArr = Array.from(tabs); + const i = tabArr.indexOf(tab); + const next = e.key === 'ArrowRight' + ? tabArr[(i + 1) % tabArr.length] + : tabArr[(i - 1 + tabArr.length) % tabArr.length]; + next.click(); }); }); }); @@ -619,15 +673,18 @@ document.addEventListener('DOMContentLoaded', () => { if (!cookie) return; const handle = decodeURIComponent(cookie.split('=')[1]); - if (handle) { + if (handle && typeof handle === 'string' && handle.length > 0) { // Save to recent accounts try { const key = 'atcr_recent_handles'; - let recent = JSON.parse(localStorage.getItem(key) || '[]'); + const raw = lsGet(key); + let recent = []; + try { recent = JSON.parse(raw || '[]'); } catch (_) { recent = []; } + if (!Array.isArray(recent)) recent = []; recent = recent.filter(h => h !== handle); recent.unshift(handle); recent = recent.slice(0, 5); - localStorage.setItem(key, JSON.stringify(recent)); + lsSet(key, JSON.stringify(recent)); } catch (err) { console.error('Failed to save recent account:', err); } @@ -648,17 +705,25 @@ function initFeaturedCarousel() { if (!carousel) return; const items = carousel.querySelectorAll('.carousel-item'); - if (items.length === 0) return; + if (items.length === 0 || !items[0]) return; let intervalId = null; const intervalMs = 5000; + // Respect prefers-reduced-motion — users who opt out of animation + // shouldn't have a carousel auto-advancing every 5 seconds, and the + // smooth-scroll itself is distracting to them. Use instant scroll + // for manual nav and skip auto-advance entirely. + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const scrollBehavior = () => reduceMotion.matches ? 'auto' : 'smooth'; + // Cache the per-step scroll distance; offsetWidth forces layout, so // measuring once per resize beats once per autoplay tick. rAF-coalesces // bursty resize events. let stepPx = 0; let resizeRaf = 0; function measureStep() { + if (!items[0]) return; const gap = parseFloat(getComputedStyle(carousel).gap) || 24; stepPx = items[0].offsetWidth + gap; } @@ -674,17 +739,17 @@ function initFeaturedCarousel() { function advance() { const max = carousel.scrollWidth - carousel.clientWidth; if (carousel.scrollLeft >= max - 10) { - carousel.scrollTo({ left: 0, behavior: 'smooth' }); + carousel.scrollTo({ left: 0, behavior: scrollBehavior() }); } else { - carousel.scrollBy({ left: stepPx, behavior: 'smooth' }); + carousel.scrollBy({ left: stepPx, behavior: scrollBehavior() }); } } function retreat() { if (carousel.scrollLeft <= 10) { - carousel.scrollTo({ left: carousel.scrollWidth, behavior: 'smooth' }); + carousel.scrollTo({ left: carousel.scrollWidth, behavior: scrollBehavior() }); } else { - carousel.scrollBy({ left: -stepPx, behavior: 'smooth' }); + carousel.scrollBy({ left: -stepPx, behavior: scrollBehavior() }); } } @@ -692,6 +757,7 @@ function initFeaturedCarousel() { if (intervalId) return; if (document.visibilityState === 'hidden') return; if (carousel.scrollWidth <= carousel.clientWidth + 10) return; + if (reduceMotion.matches) return; intervalId = setInterval(advance, intervalMs); } @@ -702,14 +768,47 @@ function initFeaturedCarousel() { if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); }); if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); }); + // User-controlled pause button for WCAG 2.2.2 compliance — auto-advancing + // content must be pausable without relying on hover, which touch users + // can't produce. + const pauseBtn = document.getElementById('carousel-pause'); + let userPaused = false; + if (pauseBtn) { + const pauseIcon = pauseBtn.querySelector('.carousel-pause-icon'); + const playIcon = pauseBtn.querySelector('.carousel-play-icon'); + // Seed aria-label/aria-pressed on load so SRs announce the correct + // state before any click; without this the button reads with no + // label until the user interacts. + pauseBtn.setAttribute('aria-pressed', 'false'); + pauseBtn.setAttribute('aria-label', 'Pause carousel auto-advance'); + pauseBtn.addEventListener('click', () => { + userPaused = !userPaused; + if (userPaused) { + stopInterval(); + pauseBtn.setAttribute('aria-pressed', 'true'); + pauseBtn.setAttribute('aria-label', 'Resume carousel auto-advance'); + if (pauseIcon) pauseIcon.classList.add('hidden'); + if (playIcon) playIcon.classList.remove('hidden'); + } else { + pauseBtn.setAttribute('aria-pressed', 'false'); + pauseBtn.setAttribute('aria-label', 'Pause carousel auto-advance'); + if (pauseIcon) pauseIcon.classList.remove('hidden'); + if (playIcon) playIcon.classList.add('hidden'); + startInterval(); + } + }); + } + + // Gate mouse-based pause and visibility resume on the user's explicit + // pause state so hovering doesn't un-pause against their wish. carousel.addEventListener('mouseenter', stopInterval); - carousel.addEventListener('mouseleave', startInterval); + carousel.addEventListener('mouseleave', () => { if (!userPaused) startInterval(); }); // Pause autoplay while the tab is hidden — a scroll-snap animation on an // invisible carousel still eats compositor time on the other tab. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') stopInterval(); - else startInterval(); + else if (!userPaused) startInterval(); }); startInterval(); @@ -724,6 +823,49 @@ document.addEventListener('DOMContentLoaded', () => { } }); +// htmx error handling — fires toast on failed requests across the app. +// Servers can also emit HX-Trigger: {"toast":{"message":"...","type":"error"}} +// which htmx turns into a 'toast' CustomEvent handled below — this listener +// is the fallback for handlers that didn't set the header. +// Opt-out: any ancestor with data-suppress-htmx-toast skips the toast (use +// for components that render their own inline error state). +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; + // If server already triggered a toast via HX-Trigger, don't double up. + 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 \u2014 please sign in again' + : status === 403 ? 'Not authorized' + : status === 404 ? 'Not found' + : status === 429 ? 'Too many requests \u2014 please slow down' + : status >= 500 ? 'Server error \u2014 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 \u2014 check your connection', 'error'); +}); + +// Server-triggered toast via HX-Trigger JSON header. +// Accepts both { "toast": { "message": "...", "type": "success" } } (a custom +// 'toast' event named in the header) and CustomEvent fired through the same +// body element. Success/error/info/warning types map to showToast's internal +// types (info and warning fall through to success styling until showToast +// gains more variants). +document.body.addEventListener('toast', (evt) => { + const d = (evt && evt.detail) || {}; + const message = d.message || d.msg || ''; + if (!message) return; + const type = d.type || 'info'; + showToast(message, type); +}); + // Toast notifications (auto-dismiss after 3s). // - Uses textContent, never innerHTML — error text sometimes relays server // response bodies that could contain markup. @@ -734,16 +876,26 @@ document.addEventListener('DOMContentLoaded', () => { const TOAST_MAX = 4; const TOAST_DEDUPE_MS = 1500; -function showToast(message, type) { +// Pre-create the toast container so the aria-live region exists before the +// first announcement. If the very first toast fires before DOMContentLoaded +// (e.g. an htmx:responseError during initial boot), we still construct the +// container lazily in showToast() — but under normal flow the pre-created +// one is used. +function ensureToastContainer() { let container = document.getElementById('toast-container'); - if (!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'); - document.body.appendChild(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; +} +document.addEventListener('DOMContentLoaded', ensureToastContainer); + +function showToast(message, type) { + const container = ensureToastContainer(); // Dedupe: if an identical toast is already on screen and was added // within the dedupe window, reset its dismiss timer instead of adding diff --git a/pkg/appview/src/js/repository.js b/pkg/appview/src/js/repository.js index ae8331b..6b0ce7c 100644 --- a/pkg/appview/src/js/repository.js +++ b/pkg/appview/src/js/repository.js @@ -190,6 +190,12 @@ window.filterTags = function(query) { }); }; +// Cancel any pending filter rAF before htmx swaps the tag list; a stale +// frame would walk a detached DOM and dirty layout for nothing. +document.body.addEventListener('htmx:beforeSwap', () => { + if (filterTagsHandle) { cancelAnimationFrame(filterTagsHandle); filterTagsHandle = 0; } +}); + // ---------------------------------------- // Tag-scoped tab controller (reads config from #tag-content data attributes) // ---------------------------------------- @@ -197,13 +203,17 @@ function initTabController() { if (!document.getElementById('tag-content')) return; const validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts']; + // State per target id: 'loading' while a request is in-flight, 'loaded' + // on success. On error we clear the entry so the retry button can + // trigger a fresh fetch; without a separate 'loading' marker, a failing + // request would leave loaded[id]=true and block all retries. let loaded = {}; function lazyLoad(id, url) { - if (loaded[id]) return; - loaded[id] = true; + if (loaded[id] === 'loading' || loaded[id] === 'loaded') return; + loaded[id] = 'loading'; const target = document.getElementById(id); - if (!target) return; + if (!target) { delete loaded[id]; return; } // Abort if the request hangs. SBOM/vuln endpoints can stall when a // hold is overloaded; without a timeout the spinner spins forever. @@ -216,6 +226,8 @@ function initTabController() { return r.text(); }) .then(html => { + loaded[id] = 'loaded'; + if (!document.contains(target)) return; // swapped out while fetching target.innerHTML = html; // innerHTML doesn't execute diff --git a/pkg/appview/templates/components/hero.html b/pkg/appview/templates/components/hero.html index 6fb3f94..76eb245 100644 --- a/pkg/appview/templates/components/hero.html +++ b/pkg/appview/templates/components/hero.html @@ -4,9 +4,9 @@ */}}
-

your registry at sea.

-

- Push and pull Docker images on the AT Protocol.
+

your registry at sea.

+

+ Push and pull Docker images on the AT Protocol. Browse public registries or control your data.

@@ -23,21 +23,21 @@
-
+
{{ icon "ship" "size-8" }}

Works with Docker

Use docker push & pull. No new tools to learn.

-
+
{{ icon "anchor" "size-8" }}

Your Data

Join shared holds or captain your own storage.

-
+
{{ icon "compass" "size-8" }}
diff --git a/pkg/appview/templates/components/meta.html b/pkg/appview/templates/components/meta.html index 1c2d35c..8832aeb 100644 --- a/pkg/appview/templates/components/meta.html +++ b/pkg/appview/templates/components/meta.html @@ -1,30 +1,34 @@ {{ define "meta" }} - {{/* Title */}} - {{ .Title }} + {{/* Title falls back to SiteName if empty — better than rendering a blank + that screen readers and tab chrome handle poorly. */}} + <title>{{ or .Title .SiteName "ATCR" }} - {{/* Basic meta */}} - + {{/* Description: omit the tag entirely when empty rather than emitting + , which some SEO tools flag. */}} + {{ if .Description }}{{ end }} {{ if .Canonical }}{{ end }} {{ if .Robots }}{{ end }} {{/* OpenGraph */}} - - - + + + {{ if .Description }}{{ end }} {{ if .Canonical }}{{ end }} {{ if .OGImage }} + {{ if .OGImageAlt }}{{ end }} {{ end }} {{/* Twitter Card */}} - - + + {{ if .Description }}{{ end }} {{ if .OGImage }}{{ end }} + {{ if and .OGImage .OGImageAlt }}{{ end }} {{/* JSON-LD */}} {{ range .JSONLD }} diff --git a/pkg/appview/templates/components/modal.html b/pkg/appview/templates/components/modal.html deleted file mode 100644 index dd694fd..0000000 --- a/pkg/appview/templates/components/modal.html +++ /dev/null @@ -1,30 +0,0 @@ -{{ define "manifest-modal" }} - - - - - - -{{ end }} diff --git a/pkg/appview/templates/components/nav-brand.html b/pkg/appview/templates/components/nav-brand.html index 303d6bc..f1c68e6 100644 --- a/pkg/appview/templates/components/nav-brand.html +++ b/pkg/appview/templates/components/nav-brand.html @@ -1,6 +1,6 @@ {{ define "nav-brand" }} - - {{ .ClientName }} logo - {{ .ClientName }} + + + {{ .ClientName }} {{ end }} diff --git a/pkg/appview/templates/components/nav-theme-toggle.html b/pkg/appview/templates/components/nav-theme-toggle.html index 76eb22b..97f438e 100644 --- a/pkg/appview/templates/components/nav-theme-toggle.html +++ b/pkg/appview/templates/components/nav-theme-toggle.html @@ -3,23 +3,23 @@ -
+ {{ if or .FromFailed .ToFailed }} + {{ template "alert" (dict "Type" "warning" "Message" (printf "We couldn't fetch details for %s%s%s — showing what we have." (or (and .FromFailed .FromTag) "") (or (and .FromFailed .ToFailed) " and ") (or (and .ToFailed .ToTag) ""))) }} + {{ end }} + + {{ if and (not .FromFailed) (not .ToFailed) (eq .FromDigest .ToDigest) }} + {{ template "alert" (dict "Type" "info" "Message" "These manifests are identical — no layers or vulnerabilities changed.") }} + {{ end }} +
-

- {{ .FromTag }} - - {{ .ToTag }} +

+ + {{ .FromTag }} + + + {{ .ToTag }}

@@ -47,12 +57,12 @@ {{ if gt .Summary.VulnFixedCount 0 }}
Fixed
-
-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}
+
-{{ .Summary.VulnFixedCount }} {{ pluralize .Summary.VulnFixedCount "vuln" "vulns" }}
- {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }}C {{ end }} - {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}H {{ end }} - {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}M {{ end }} - {{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}L{{ end }} + {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }} Critical {{ end }} + {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }} High {{ end }} + {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }} Medium {{ end }} + {{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }} Low{{ end }}
{{ end }} @@ -61,12 +71,12 @@ {{ if gt .Summary.VulnNewCount 0 }}
New
-
+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}
+
+{{ .Summary.VulnNewCount }} {{ pluralize .Summary.VulnNewCount "vuln" "vulns" }}
- {{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }}C {{ end }} - {{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}H {{ end }} - {{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}M {{ end }} - {{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}L{{ end }} + {{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }} Critical {{ end }} + {{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }} High {{ end }} + {{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }} Medium {{ end }} + {{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }} Low{{ end }}
{{ end }} diff --git a/pkg/appview/templates/pages/digest.html b/pkg/appview/templates/pages/digest.html index 824fcc9..0b7b336 100644 --- a/pkg/appview/templates/pages/digest.html +++ b/pkg/appview/templates/pages/digest.html @@ -26,7 +26,7 @@
{{ if .Manifest.Tags }} -

{{ range $i, $tag := .Manifest.Tags }}{{ if $i }}{{ if lt $i 3 }}, {{ end }}{{ end }}{{ if lt $i 3 }}{{ $tag }}{{ end }}{{ end }}{{ if gt (len .Manifest.Tags) 3 }} +{{ sub (len .Manifest.Tags) 3 }} more{{ end }}

+

{{ range $i, $tag := .Manifest.Tags }}{{ if lt $i 3 }}{{ if $i }}{{ end }}{{ $tag }}{{ end }}{{ end }}{{ if gt (len .Manifest.Tags) 3 }}+{{ sub (len .Manifest.Tags) 3 }} more{{ end }}

{{ else }}

{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}

{{ end }} @@ -46,7 +46,9 @@
- {{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }} + {{ if not .Manifest.CreatedAt.IsZero }} + {{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }} + {{ end }}
@@ -73,25 +75,28 @@
-
-
+
{{ if .Manifest.IsManifestList }} - {{ if .Manifest.Platforms }} -
+
{{ icon "loader" "size-6 animate-spin text-base-content/40" }} Loading layers and vulnerabilities...
+ {{ else }} +

No platform manifests found for this image index.

{{ end }} {{ else }} {{ template "digest-content" . }} diff --git a/pkg/appview/templates/pages/home.html b/pkg/appview/templates/pages/home.html index b6e520a..f1db41e 100644 --- a/pkg/appview/templates/pages/home.html +++ b/pkg/appview/templates/pages/home.html @@ -16,10 +16,15 @@
{{ if .FeaturedRepos }} -
+

Featured

+ {{ if gt (len .FeaturedRepos) 1 }}
+ @@ -27,6 +32,7 @@ {{ icon "chevron-right" "size-5" }}
+ {{ end }}
{{ end }} + {{ if and (not .FeaturedRepos) (not .RecentRepos) }} + {{ if .HasError }} + {{ template "state-error" (dict + "Title" "We couldn't load the home page" + "Subtext" "Something went wrong fetching repositories. This is usually temporary." + "RetryURL" "/" + ) }} + {{ else }} +
+ {{ if .User }} + {{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }} +

Nothing here yet

+

Push your first image to get started.

+ {{ template "docker-command" (print "docker push atcr.io/" .User.Handle "/my-image:latest") }} + {{ else }} + {{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }} +

No public repositories yet

+

Be the first to push an image.

+ {{ end }} +
+ {{ end }} + {{ else if .HasError }} + {{/* Partial failure: render a non-blocking warning above what did load. */}} + {{ template "alert" (dict "Type" "warning" "Message" "Some sections couldn't be loaded right now. Try refreshing the page.") }} + {{ end }}
diff --git a/pkg/appview/templates/pages/install.html b/pkg/appview/templates/pages/install.html index 9c5375c..cc743b5 100644 --- a/pkg/appview/templates/pages/install.html +++ b/pkg/appview/templates/pages/install.html @@ -16,12 +16,16 @@

Quick Install

-
- - +
+ +
-
+

Using install script

curl -fsSL {{ .SiteURL }}/static/install.sh | bash
@@ -35,7 +39,7 @@
-
diff --git a/pkg/appview/templates/pages/learn-more.html b/pkg/appview/templates/pages/learn-more.html index e6bcd7a..c218214 100644 --- a/pkg/appview/templates/pages/learn-more.html +++ b/pkg/appview/templates/pages/learn-more.html @@ -164,11 +164,13 @@

diff --git a/pkg/appview/templates/pages/login.html b/pkg/appview/templates/pages/login.html index 247a01e..a45ac40 100644 --- a/pkg/appview/templates/pages/login.html +++ b/pkg/appview/templates/pages/login.html @@ -17,7 +17,19 @@ {{ icon "circle-x" "size-5" }} {{ if eq .Error "handle_required" }} - Please enter your Atmosphere Account + Please enter your Atmosphere Account. + {{ else if eq .Error "invalid_handle" }} + That handle doesn't look right. Check for typos and try again. + {{ else if eq .Error "pds_unreachable" }} + We couldn't reach your PDS. It may be offline — try again in a minute. + {{ else if eq .Error "state_mismatch" }} + Your sign-in session expired before we finished. Please start over. + {{ else if eq .Error "access_denied" }} + You declined to authorize {{ .ClientShortName }}. No changes were made. + {{ else if eq .Error "invalid_scope" }} + Your PDS refused the requested permissions. Try again or contact your PDS operator. + {{ else if eq .Error "session_expired" }} + Your session expired. Please sign in again. {{ else if eq .Error "auth_failed" }} Authentication failed. Please try again. {{ else }} @@ -27,7 +39,8 @@ {{ end }} -
+
@@ -46,7 +59,7 @@ {{ if .Error }}aria-invalid="true" aria-describedby="login-error"{{ end }} />
- diff --git a/pkg/appview/templates/pages/privacy.html b/pkg/appview/templates/pages/privacy.html index 654b041..cb027fb 100644 --- a/pkg/appview/templates/pages/privacy.html +++ b/pkg/appview/templates/pages/privacy.html @@ -9,8 +9,8 @@ {{ template "nav" . }}
-

Privacy Policy - {{ .CompanyName }} ({{ .SiteURL }})

-

Last updated: January 2025

+

Privacy Policy — {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}

+

Last updated: {{ .LastUpdated }}

@@ -321,16 +321,12 @@

Please include your AT Protocol DID or handle so we can verify your identity.

We will respond to requests within 30 days (GDPR) or 45 days (CCPA).

+ + {{ with .Jurisdiction }} +

This policy is governed by the laws of {{ . }}.

+ {{ end }}
-
-

Contact

- -

For questions about this privacy policy or to exercise your data rights, contact:

- -

Email: privacy@{{ .SiteURL }}

-

Website: https://{{ .SiteURL }}

-
diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 26257c0..0a2df3b 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -15,13 +15,13 @@
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
-

+

{{ .Owner.Handle }} - / + {{ .Repository.Name }}

{{ if .Repository.Description }} -

{{ .Repository.Description }}

+

{{ .Repository.Description }}

{{ end }}
@@ -29,14 +29,22 @@
- {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }} + {{ if .StatsAvailable }} + {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name "IsAuthenticated" (ne .User nil)) }} {{ template "pull-count" (dict "PullCount" .Stats.PullCount) }} + {{ else }} + {{/* Stats query failed — show a subdued indicator rather + than zeros that could be mistaken for real counts. */}} + + {{ icon "alert-circle" "size-4 inline" }} Stats unavailable + + {{ end }} {{ if .TagCount }} - + {{ icon "tag" "size-4" }} {{ .TagCount }} {{ end }} - {{ if .Stats.LastPush }} + {{ if and .StatsAvailable .Stats.LastPush }} Updated {{ timeAgoShort (derefTime .Stats.LastPush) }} @@ -57,7 +65,7 @@ {{ .SPDXID }} {{ else }} - + {{ .Name }} {{ end }} @@ -83,25 +91,30 @@
{{ icon "tag" "size-6 text-base-content/60" }} + + {{ if gt (len .AllTags) 1 }} @@ -113,7 +126,7 @@ {{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }} {{ end }}
- {{ else }} + {{ else if gt (len .SelectedTag.Info.Platforms) 0 }} {{ $p := index .SelectedTag.Info.Platforms 0 }} {{ if $p.OS }}
@@ -141,12 +154,21 @@
- - - - - - - - - - - - -
Device NameIP AddressCreatedLast UsedActions
{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...
- - - - - - - - - - - + +
+ {{ template "settings-panel" . }}
diff --git a/pkg/appview/templates/pages/terms.html b/pkg/appview/templates/pages/terms.html index e9e1508..b2830a5 100644 --- a/pkg/appview/templates/pages/terms.html +++ b/pkg/appview/templates/pages/terms.html @@ -9,8 +9,8 @@ {{ template "nav" . }}
-

Terms of Service - {{ .CompanyName }} ({{ .SiteURL }})

-

Last updated: January 2025

+

Terms of Service — {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}

+

Last updated: {{ .LastUpdated }}

These Terms of Service ("Terms") govern your use of {{ .CompanyName }} ("{{ .SiteURL }}", "the Service", "we", "us", "our"). By using the Service, you agree to these Terms. If you do not agree, do not use the Service.

diff --git a/pkg/appview/templates/pages/user.html b/pkg/appview/templates/pages/user.html index 2320a66..858fd9a 100644 --- a/pkg/appview/templates/pages/user.html +++ b/pkg/appview/templates/pages/user.html @@ -15,24 +15,24 @@ {{ if .ViewedUser.Avatar }}
- {{ .ViewedUser.Handle }} +
{{ else if .HasProfile }} -
+ {{ else }} -
+ {{ end }} -
-

{{ .ViewedUser.Handle }}

+
+

{{ .ViewedUser.Handle }}

{{ if or (eq .SupporterBadge "Captain") (eq .SupporterBadge "owner") }} {{ .SupporterBadge }} {{ else if .SupporterBadge }} @@ -46,6 +46,12 @@

This user hasn't set up their {{ .ClientShortName }} profile yet.

+ {{ else if .HasError }} + {{ template "state-error" (dict + "Title" "We couldn't load their images" + "Subtext" "The database had trouble fetching this profile. Try refreshing in a moment." + "RetryURL" (printf "/u/%s" .ViewedUser.Handle) + ) }} {{ else }}
{{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No images yet.") }} diff --git a/pkg/appview/templates/partials/alert.html b/pkg/appview/templates/partials/alert.html index 420e730..5e2b306 100644 --- a/pkg/appview/templates/partials/alert.html +++ b/pkg/appview/templates/partials/alert.html @@ -1,9 +1,13 @@ {{ define "alert" }} {{ if eq .Type "success" }} -
{{ icon "check" "size-5" }} {{ .Message }}
+
{{ icon "check-circle" "size-5 shrink-0" }} {{ .Message }}
{{ else if eq .Type "error" }} -
{{ icon "alert-circle" "size-5" }} {{ .Message }}
+ +{{ else if eq .Type "warning" }} + +{{ else if eq .Type "info" }} +
{{ icon "info" "size-5 shrink-0" }} {{ .Message }}
{{ else }} -
{{ .Message }}
+
{{ .Message }}
{{ end }} {{ end }} diff --git a/pkg/appview/templates/partials/attestation-details.html b/pkg/appview/templates/partials/attestation-details.html index fab5914..c72c59e 100644 --- a/pkg/appview/templates/partials/attestation-details.html +++ b/pkg/appview/templates/partials/attestation-details.html @@ -1,20 +1,29 @@ {{ define "attestation-details" }} {{ if .Error }} -

{{ .Error }}

+

{{ .Error }}

{{ else }}
-

{{ len .Attestations }} attestation{{ if gt (len .Attestations) 1 }}s{{ end }} attached

+

{{ len .Attestations }} {{ pluralize (len .Attestations) "attestation" "attestations" }} attached

{{ range .Attestations }}
+ {{/* "Unknown" / "Binary" etc. predicates shouldn't read as success-green. */}} + {{ if or (eq .PredicateType "Unknown") (eq .PredicateType "Binary") }} + {{ .PredicateType }} + {{ else }} {{ .PredicateType }} + {{ end }} {{ .Digest }}
{{ if .NeedsLogin }} + {{ if $.LoginURL }}

Log in to view attestation content

+ {{ else }} +

Log in to view attestation content

+ {{ end }} {{ else if .FetchError }} -

{{ .FetchError }}

+

{{ .FetchError }}

{{ else if .RawJSON }}
View content @@ -23,9 +32,11 @@
{{ else if .Size }} -

Binary content ({{ .Size }} bytes) — cannot display inline

+

Binary content ({{ humanizeBytes .Size }}) — cannot display inline

{{ end }}
+ {{ else }} +

No attestations attached to this manifest.

{{ end }}
{{ end }} diff --git a/pkg/appview/templates/partials/devices-table.html b/pkg/appview/templates/partials/devices-table.html index 93b226d..5d46f30 100644 --- a/pkg/appview/templates/partials/devices-table.html +++ b/pkg/appview/templates/partials/devices-table.html @@ -1,8 +1,8 @@ {{ define "devices-table" }} {{ range .Devices }} - {{ .Name }} - {{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }} + {{ .Name }} + {{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }} {{ formatDate .CreatedAt }} {{ if isZeroTime .LastUsed }}Never{{ else }}{{ formatDate .LastUsed }}{{ end }} @@ -10,7 +10,8 @@ hx-delete="/api/devices/{{ .ID }}" hx-target="#device-{{ .ID }}" hx-swap="delete" - hx-confirm="Revoke access for {{ .Name }}?"> + hx-confirm="Revoke access for {{ .Name }}?" + aria-label="Revoke access for {{ .Name }}"> {{ icon "trash-2" "size-4" }} diff --git a/pkg/appview/templates/partials/diff-content.html b/pkg/appview/templates/partials/diff-content.html index 77b6ba8..87855fc 100644 --- a/pkg/appview/templates/partials/diff-content.html +++ b/pkg/appview/templates/partials/diff-content.html @@ -7,18 +7,19 @@ {{ if .LayerDiff }}
+ - - - - + + + + {{ range .LayerDiff }} - +
Layer differences
#CommandSizeChange#CommandSize
{{ if eq .Status "added" }}+{{ else if eq .Status "removed" }}-{{ else if eq .Status "rebuilt" }}~{{ end }}{{/* Glyph + sr-only label: color is redundant information. */}}{{ if eq .Status "added" }}Added{{ else if eq .Status "removed" }}Removed{{ else if eq .Status "rebuilt" }}Rebuilt{{ else }}Unchanged{{ end }} {{ .Layer.Index }} {{ if .Layer.Command }} @@ -48,7 +49,18 @@

Vulnerabilities

{{ if not .HasVulnData }} -

Vulnerability scan data not available for both manifests

+ {{/* Branch on per-side scan status so users can tell "not scanned + yet" from "hold offline" from transient errors. */}} + {{ if or (eq .FromScanStatus "hold-unreachable") (eq .ToScanStatus "hold-unreachable") }} +
+ {{ icon "wifi-off" "size-4 shrink-0" }} + We couldn't reach the hold to fetch scan data. Try again in a moment. +
+ {{ else if or (eq .FromScanStatus "no-data") (eq .ToScanStatus "no-data") }} +

Neither manifest has been scanned yet. Vulnerability comparison will appear after both scans complete.

+ {{ else }} +

Vulnerability scan data isn't available for both manifests.

+ {{ end }} {{ else }} @@ -62,26 +74,27 @@
+ - - - - + + + + {{ range .FixedVulns }} - - + + {{ end }} @@ -102,27 +115,28 @@
Vulnerabilities fixed in the newer manifest
CVESeverityPackageWasCVESeverityPackageWas
- {{ if .CVEURL }}{{ .CVEID }} - {{ else }}{{ .CVEID }}{{ end }} + {{ if .CVEURL }}{{ or .CVEID "—" }} + {{ else }}{{ or .CVEID "—" }}{{ end }} - {{ .Severity }} + {{ severityLabel .Severity }} {{ .Package }}{{ .Version }}{{ .Package }}{{ .Version }}
+ - - - - - + + + + + {{ range .NewVulns }} - - + + {{ end }} @@ -143,27 +157,28 @@
Vulnerabilities new to the newer manifest
CVESeverityPackageVersionFixCVESeverityPackageVersionFix
- {{ if .CVEURL }}{{ .CVEID }} - {{ else }}{{ .CVEID }}{{ end }} + {{ if .CVEURL }}{{ or .CVEID "—" }} + {{ else }}{{ or .CVEID "—" }}{{ end }} - {{ .Severity }} + {{ severityLabel .Severity }} {{ .Package }}{{ .Version }}{{ .Package }}{{ .Version }} {{ .FixedIn }}
+ - - - - - + + + + + {{ range .UnchangedVulns }} - - + + {{ end }} diff --git a/pkg/appview/templates/partials/digest-content.html b/pkg/appview/templates/partials/digest-content.html index 7101eaa..2a3fa9a 100644 --- a/pkg/appview/templates/partials/digest-content.html +++ b/pkg/appview/templates/partials/digest-content.html @@ -13,11 +13,12 @@ {{ if .Layers }}
Vulnerabilities present in both manifests
CVESeverityPackageVersionFixCVESeverityPackageVersionFix
- {{ if .CVEURL }}{{ .CVEID }} - {{ else }}{{ .CVEID }}{{ end }} + {{ if .CVEURL }}{{ or .CVEID "—" }} + {{ else }}{{ or .CVEID "—" }}{{ end }} - {{ .Severity }} + {{ severityLabel .Severity }} {{ .Package }}{{ .Version }}{{ .Package }}{{ .Version }} {{ .FixedIn }}
+ - - - + + + @@ -43,8 +44,8 @@
- -
+ +
{{ if .VulnData }} {{ template "vuln-details" .VulnData }} {{ else }} @@ -52,8 +53,8 @@ {{ end }}
- -
+ +
{{ if .SbomData }} {{ template "sbom-details" .SbomData }} {{ else }} diff --git a/pkg/appview/templates/partials/health-badge.html b/pkg/appview/templates/partials/health-badge.html index 75f59a6..d487959 100644 --- a/pkg/appview/templates/partials/health-badge.html +++ b/pkg/appview/templates/partials/health-badge.html @@ -1,10 +1,24 @@ {{ define "health-badge" }} {{ if .Pending }} -{{ icon "refresh-ccw" "size-3" }} Checking... + hx-swap="outerHTML">{{ icon "refresh-ccw" "size-3 animate-spin" }} Checking… {{ else if not .Reachable }} -{{ icon "triangle-alert" "size-3" }} Offline + {{/* Branch on classified reason so the tooltip tells operators what + kind of failure they're seeing, not just "it's down". */}} + {{ if eq .Reason "dns" }} +{{ icon "triangle-alert" "size-3" }} DNS failed + {{ else if eq .Reason "tls" }} +{{ icon "triangle-alert" "size-3" }} TLS error + {{ else if eq .Reason "refused" }} +{{ icon "triangle-alert" "size-3" }} Refused + {{ else if eq .Reason "timeout" }} +{{ icon "triangle-alert" "size-3" }} Timeout + {{ else if eq .Reason "http" }} +{{ icon "triangle-alert" "size-3" }} HTTP error + {{ else }} +{{ icon "triangle-alert" "size-3" }} Offline + {{ end }} {{ end }} {{ end }} diff --git a/pkg/appview/templates/partials/hold_card.html b/pkg/appview/templates/partials/hold_card.html index fb63658..73d9234 100644 --- a/pkg/appview/templates/partials/hold_card.html +++ b/pkg/appview/templates/partials/hold_card.html @@ -4,12 +4,12 @@
-

{{ .DisplayName }}

- Active +

{{ or .DisplayName .DID }}

{{ if eq .Membership "owner" }}Owner {{ else }}Crew{{ end }} {{ if eq .Status "online" }}Online {{ else if eq .Status "offline" }}Offline + {{ else }}Unknown {{ end }}
{{ .DID }} @@ -21,7 +21,8 @@
+ hx-swap="innerHTML" + hx-on::after-request="if(!event.detail.successful) this.innerHTML='

Storage unavailable.

'">

{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...

diff --git a/pkg/appview/templates/partials/hold_selector.html b/pkg/appview/templates/partials/hold_selector.html index a672c9e..7422692 100644 --- a/pkg/appview/templates/partials/hold_selector.html +++ b/pkg/appview/templates/partials/hold_selector.html @@ -8,27 +8,25 @@ {{ end }} + {{ if .MemberHolds }} - {{ range .AllHolds }} - {{ if ne .Membership "eligible" }} + {{ range .MemberHolds }} - {{ end }} {{ end }} + {{ end }} - {{ range .AllHolds }}{{ if eq .Membership "eligible" }} + {{ if .EligibleHolds }} - {{ range $.AllHolds }} - {{ if eq .Membership "eligible" }} + {{ range .EligibleHolds }} - {{ end }} {{ end }} - {{ break }}{{ end }}{{ end }} + {{ end }} diff --git a/pkg/appview/templates/partials/image-advisor-results.html b/pkg/appview/templates/partials/image-advisor-results.html index fe94a23..fdab1b5 100644 --- a/pkg/appview/templates/partials/image-advisor-results.html +++ b/pkg/appview/templates/partials/image-advisor-results.html @@ -2,7 +2,7 @@ {{ if eq .Error "upgrade_required" }}
{{ icon "sparkles" "size-4" }} - AI Image Advisor is a paid feature. Upgrade your plan to unlock image analysis. + AI Image Advisor is a paid feature. Upgrade your plan to unlock image analysis.
{{ else if .Error }}
@@ -18,19 +18,20 @@
Image layers
#CommandSize#CommandSize
+ - - - - - + + + + + {{ range .Suggestions }} - + @@ -49,10 +50,10 @@ {{ else if eq .Effort "medium" }} medium {{ else }} - high + high {{ end }} -
AI optimization suggestions for this image
ActionCategoryImpactEffortDetailActionCategoryImpactEffortDetail
{{ .Action }}{{ .Action }} {{ .Category }} + {{ .Detail }} {{ if gt .CVEsFixed 0 }} {{ .CVEsFixed }} CVEs @@ -66,7 +67,7 @@
-

Generated by Claude Haiku. Suggestions are advisory only.

+

Generated by {{ or .Model "Claude" }}. Suggestions are advisory only.

{{ else }} diff --git a/pkg/appview/templates/partials/layers-section.html b/pkg/appview/templates/partials/layers-section.html index bce1b6a..6a49598 100644 --- a/pkg/appview/templates/partials/layers-section.html +++ b/pkg/appview/templates/partials/layers-section.html @@ -7,14 +7,20 @@ Show empty layers + {{ if .ConfigFetchError }} + {{/* Hold is reachable but the config blob fetch failed, so layer + commands are missing. DB layers are still rendered below. */}} + {{ template "alert" (dict "Type" "warning" "Message" "Layer commands couldn't be loaded from the hold. Showing what we have from the registry.") }} + {{ end }} {{ if .Layers }}
+ - - - + + + @@ -24,6 +30,8 @@ diff --git a/pkg/appview/templates/partials/other_holds_table.html b/pkg/appview/templates/partials/other_holds_table.html index 2c6d1f4..928d2e0 100644 --- a/pkg/appview/templates/partials/other_holds_table.html +++ b/pkg/appview/templates/partials/other_holds_table.html @@ -5,19 +5,21 @@
Image layer history
#CommandSize#CommandSize
{{ if .Command }} {{ .Command }} + {{ else if not .EmptyLayer }} + — no command recorded {{ end }} {{ humanizeBytes .Size }}
+ - - - - + + + + {{ range . }} + {{ else }} + {{ end }}
Other holds you are a member of
HoldRoleStatusStorageHoldRoleStatusStorage
- {{ .DisplayName }} + {{ or .DisplayName .DID }} + {{ .DID }} {{ if eq .Membership "owner" }}Owner @@ -40,6 +42,7 @@ hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true" hx-trigger="load, tab:storage from:body once" hx-swap="innerHTML" + hx-on::response-error="this.innerHTML='—'" class="text-sm font-mono"> ... diff --git a/pkg/appview/templates/partials/repo-tag-section.html b/pkg/appview/templates/partials/repo-tag-section.html index 8f7f3f7..ad3d6f0 100644 --- a/pkg/appview/templates/partials/repo-tag-section.html +++ b/pkg/appview/templates/partials/repo-tag-section.html @@ -1,5 +1,5 @@ {{ define "repo-tag-section" }} -
+
{{ if .SelectedTag }} {{ template "pull-command-switcher" (dict "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository.Name "Tag" .SelectedTag.Info.Tag.Tag "ArtifactType" .ArtifactType "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }} @@ -9,9 +9,9 @@
Hosted on: {{ range .NonDefaultHolds }} - {{ displayHoldDID . }} + {{ displayHoldDID . }} {{ end }} - (different from your default hold) + (not your default hold)
{{ end }} @@ -36,9 +36,11 @@
Vulnerabilities
+ {{ if .SelectedTag.Info.Platforms }} {{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }} Loading... + {{ end }}
@@ -77,7 +79,7 @@ {{ icon "loader" "size-4 animate-spin" }} -
+
{{ end }} @@ -157,6 +159,20 @@
{{ if .ReadmeHTML }} {{ .ReadmeHTML }} + {{ else if .ReadmeFetchFailed }} + {{/* README URL is configured but the fetch failed — distinguish + from "no README yet" so owners know the source is broken, + not missing. */}} +
+ {{ icon "alert-triangle" "size-12 text-warning mx-auto" }} +

We couldn't load the README

+

The configured README source didn't respond. It may be rate-limited or private.

+ {{ if .IsOwner }} + + {{ end }} +
{{ else }} {{ if .IsOwner }}
diff --git a/pkg/appview/templates/partials/repo-tags.html b/pkg/appview/templates/partials/repo-tags.html index fd37115..9189239 100644 --- a/pkg/appview/templates/partials/repo-tags.html +++ b/pkg/appview/templates/partials/repo-tags.html @@ -19,7 +19,7 @@ {{ end }} {{ if and .ViewerDefaultHold .Entry.HoldEndpoint (ne .Entry.HoldEndpoint .ViewerDefaultHold) }} - {{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }} + {{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }} {{ end }}
@@ -29,7 +29,7 @@
{{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}
No platform details available for this manifest.
@@ -115,7 +117,9 @@ hx-get="/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}?offset={{ .NextOffset }}" hx-target="#tags-list" hx-swap="beforeend" - hx-on::before-request="document.getElementById('load-more-container').remove()"> + hx-indicator="#load-more-spinner" + hx-on::after-request="if(event.detail.successful)document.getElementById('load-more-container').remove()"> + Load More
@@ -137,7 +141,7 @@
- Sort by +
+
{{ if $.IsOwner }} diff --git a/pkg/appview/templates/partials/sbom-details.html b/pkg/appview/templates/partials/sbom-details.html index 57fbde5..ba8aaa6 100644 --- a/pkg/appview/templates/partials/sbom-details.html +++ b/pkg/appview/templates/partials/sbom-details.html @@ -1,11 +1,14 @@ {{ define "sbom-details" }} {{ if .Error }} -

{{ .Error }}

- {{ if .ScannedAt }}

Scanned: {{ .ScannedAt }}

{{ end }} + + {{ if .ScannedAt }}

Scanned: {{ .ScannedAt }}

{{ end }} {{ else }}
- {{ .Total }} packages + {{ .Total }} {{ pluralize .Total "package" "packages" }} {{ if .Packages }}