large list of ui fixes for accessibility/hardening etc.

This commit is contained in:
Evan Jarrett
2026-04-21 21:18:13 -05:00
parent 23484645c0
commit f057f169f0
107 changed files with 2475 additions and 1163 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ when:
branch: ["main"]
engine: kubernetes
image: golang:1.25-trixie
image: golang:1.26-trixie
architecture: amd64
steps:
@@ -12,7 +12,7 @@ when:
tag: ["v*"]
engine: kubernetes
image: golang:1.25-trixie
image: golang:1.26-trixie
architecture: amd64
environment:
+1 -1
View File
@@ -5,7 +5,7 @@ when:
branch: ["main"]
engine: kubernetes
image: golang:1.25-trixie
image: golang:1.26-trixie
architecture: amd64
steps:
+6 -1
View File
@@ -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
+26 -3
View File
@@ -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..."
+4 -3
View File
@@ -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).
+2 -2
View File
@@ -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
+6 -2
View File
@@ -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},
+1
View File
@@ -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
}
+4
View File
@@ -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(),
}
}
+1 -1
View File
@@ -527,7 +527,7 @@ const deviceSuccessTemplate = `
<h1>✓ Device Authorized!</h1>
<p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
<p>You can now close this window and return to your terminal.</p>
<p><a href="/settings#devices">View your authorized devices</a></p>
<p><a href="/settings/devices">View your authorized devices</a></p>
</div>
</body>
</html>
+52 -10
View File
@@ -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,
+82 -38
View File
@@ -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)
}
}
}
+32
View File
@@ -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)
}
+12 -6
View File
@@ -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 {
+16 -6
View File
@@ -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{{
+44 -5
View File
@@ -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 {
+44 -10
View File
@@ -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),
}
+24 -4
View File
@@ -3,18 +3,23 @@ package handlers
// PageMeta holds all metadata for a page's <head> 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
+42 -30
View File
@@ -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
+25 -6
View File
@@ -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, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML">%s</span>`,
template.HTMLEscapeString(d), buf.String())
}
return
}
+10 -7
View File
@@ -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)
}
}
+95 -62
View File
@@ -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)
}
}
+198 -135
View File
@@ -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
+11 -2
View File
@@ -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, `<div class="storage-error"><i data-lucide="alert-circle"></i> %s</div>`, 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, `<p class="text-sm text-error">%s</p>`, message)
}
}
func (h *StorageHandler) renderNoHold(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="storage-info"><i data-lucide="info"></i> No hold configured. Set a default hold above to see storage usage.</div>`)
fmt.Fprint(w, `<p class="flex items-center gap-2 text-sm text-base-content/70"><svg class="icon size-4 shrink-0" aria-hidden="true"><use href="/icons.svg#info"></use></svg> No hold configured. Set a default hold above to see storage usage.</p>`)
}
// humanizeBytes converts bytes to human-readable format
+1 -1
View File
@@ -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 {
+10 -3
View File
@@ -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 {
+5 -3
View File
@@ -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,
})
}
+28 -14
View File
@@ -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"},
}
}
+4 -2
View File
@@ -19,7 +19,6 @@
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
<symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol>
<symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol>
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
@@ -42,7 +41,10 @@
<symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
<symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol>
<symbol id="pause" viewBox="0 0 24 24"><rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
<symbol id="play" viewBox="0 0 24 24"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/></symbol>
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol>
<symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol>
@@ -65,7 +67,7 @@
<symbol id="upload" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol>
<symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol>
<symbol id="webhook" viewBox="0 0 24 24"><path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/></symbol>
<symbol id="wifi-off" viewBox="0 0 24 24"><path d="M12 20h.01"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/><path d="M5 12.859a10 10 0 0 1 5.17-2.69"/><path d="M19 12.859a10 10 0 0 0-2.007-1.523"/><path d="M2 8.82a15 15 0 0 1 4.177-2.643"/><path d="M22 8.82a15 15 0 0 0-11.288-3.764"/><path d="m2 2 20 20"/></symbol>
<symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
<symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+14 -14
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,26 +2,31 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://atcr.io/</loc>
<lastmod>2026-04-21</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://atcr.io/search</loc>
<lastmod>2026-04-21</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://atcr.io/install</loc>
<lastmod>2026-04-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://atcr.io/privacy</loc>
<lastmod>2026-04-21</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://atcr.io/terms</loc>
<lastmod>2026-04-21</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
+104
View File
@@ -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 "<!-- raw HTML omitted -->"; 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: "<script>alert('xss')</script>",
wantAbsent: []string{"<script>", "alert(", "</script>"},
},
{
name: "script tag in fenced code block is escaped, not executed",
input: "```\n<script>alert('xss')</script>\n```",
// goldmark HTML-escapes content inside code blocks
wantAbsent: []string{"<script>alert("},
wantPresent: []string{"&lt;script&gt;"},
},
{
name: "javascript: protocol in markdown link",
input: "[click me](javascript:alert('xss'))",
wantAbsent: []string{"javascript:"},
},
{
name: "javascript: protocol in inline HTML anchor",
input: `<a href="javascript:alert('xss')">click</a>`,
wantAbsent: []string{"javascript:"},
},
{
name: "img onerror via inline HTML",
input: `<img src="x" onerror="alert('xss')">`,
wantAbsent: []string{"onerror", "alert("},
},
{
name: "img with injected attribute via markdown image syntax",
input: `![alt](x" onerror="alert('xss'))`,
// Malformed URL — goldmark rejects the image and renders it as literal escaped text.
// The actual XSS vector (an <img> with an onerror attribute) cannot form.
wantAbsent: []string{"<img"},
},
{
name: "svg onload",
input: `<svg onload="alert('xss')"><circle r="10"/></svg>`,
wantAbsent: []string{"onload", "alert("},
},
{
name: "iframe element",
input: `<iframe src="https://evil.com"></iframe>`,
wantAbsent: []string{"<iframe"},
},
{
name: "style tag",
input: `<style>body { background: red; }</style>`,
wantAbsent: []string{"<style>"},
},
{
name: "meta refresh redirect",
input: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
wantAbsent: []string{"<meta"},
},
{
name: "data URI in img src",
input: `<img src="data:text/html,<script>alert(1)</script>">`,
wantAbsent: []string{"data:text/html", "alert("},
},
{
name: "form action exfiltration",
input: `<form action="https://evil.com"><button>Submit</button></form>`,
wantAbsent: []string{"<form", "action="},
},
{
name: "onclick on arbitrary element",
input: `<p onclick="alert('xss')">click me</p>`,
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)))
}
+10 -1
View File
@@ -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)
+1
View File
@@ -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,
+42 -9
View File
@@ -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 (<sm) always shows the button regardless. */
.cmd .cmd-copy {
@apply opacity-100;
}
@media (hover: hover) and (pointer: fine) {
.cmd .cmd-copy {
@apply sm:opacity-0 transition-opacity;
}
.cmd:hover .cmd-copy,
.cmd .cmd-copy:focus,
.cmd .cmd-copy:focus-visible {
@apply opacity-100;
}
}
/* ----------------------------------------
@@ -536,21 +567,23 @@
/* ----------------------------------------
HELM BRAND COLOR (official Helm blue #0F1689)
Tokens live on :root (--color-helm-{light,dark}) so the value is
declared once and any future brand shift updates every consumer.
---------------------------------------- */
.text-helm {
@apply text-[oklch(31%_0.181_267.5)];
color: var(--color-helm-light);
}
[data-theme="dark"] .text-helm {
@apply text-[oklch(64.6%_0.19_273.2)];
color: var(--color-helm-dark);
}
.badge-helm {
--badge-color: oklch(31% 0.181 267.5);
--badge-color: var(--color-helm-light);
}
[data-theme="dark"] .badge-helm {
--badge-color: oklch(64.6% 0.19 273.2);
--badge-color: var(--color-helm-dark);
}
/* ----------------------------------------
+182 -30
View File
@@ -1,6 +1,16 @@
// Safe localStorage wrappers. Safari private mode, disabled storage, and
// quota-exceeded all throw from getItem/setItem — absorb those failures so
// individual features degrade silently instead of crashing the page.
function lsGet(key) {
try { return localStorage.getItem(key); } catch (_) { return null; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch (_) { /* quota/disabled */ }
}
// Theme management (system / light / dark)
function getThemePreference() {
return localStorage.getItem('theme') || 'system';
return lsGet('theme') || 'system';
}
function getEffectiveTheme(pref) {
@@ -20,7 +30,7 @@ function applyTheme() {
}
function setTheme(theme) {
localStorage.setItem('theme', theme);
lsSet('theme', theme);
applyTheme();
closeThemeDropdown();
}
@@ -51,6 +61,18 @@ function closeThemeDropdown() {
});
}
// Sync aria-expanded on theme-toggle summaries with the native <details>
// 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 = '<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> 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 <table> (thead + tbody) as CSV (RFC 4180 quoting)
// Serialize a <table> (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
+29 -9
View File
@@ -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 <script> tags — re-create them
target.querySelectorAll('script').forEach(old => {
@@ -226,7 +238,10 @@ function initTabController() {
if (typeof window.htmx !== 'undefined') window.htmx.process(target);
})
.catch(err => {
loaded[id] = false;
// Clear state immediately so the retry button (or another
// tab switch) can fire a fresh request.
delete loaded[id];
if (!document.contains(target)) return;
const timedOut = err && err.name === 'AbortError';
const msg = timedOut
? 'This section took too long to load.'
@@ -254,17 +269,22 @@ function initTabController() {
function contentUrl(section) {
const el = document.getElementById('tag-content');
if (!el) return null;
if (!el || !el.dataset) return null;
const digest = el.dataset.digest;
if (!digest) return null;
return '/api/digest-content/' + el.dataset.owner + '/' + el.dataset.repo +
const owner = el.dataset.owner;
const repo = el.dataset.repo;
if (!digest || !owner || !repo) return null;
return '/api/digest-content/' + owner + '/' + repo +
'?digest=' + encodeURIComponent(digest) + '&section=' + section;
}
function tagsUrl() {
const el = document.getElementById('tag-content');
if (!el) return null;
return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
if (!el || !el.dataset) return null;
const owner = el.dataset.owner;
const repo = el.dataset.repo;
if (!owner || !repo) return null;
return '/api/repo-tags/' + owner + '/' + repo;
}
window.diffToTag = function(e, link) {
+30 -6
View File
@@ -183,6 +183,7 @@ class SailorTypeahead {
const row = document.createElement('div');
row.className = 'sailor-typeahead-item';
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', 'false');
row.dataset.index = String(index);
row.dataset.handle = actor.handle;
@@ -344,7 +345,6 @@ class SailorTypeahead {
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'sailor-typeahead-clear';
clearBtn.tabIndex = -1; // keep out of tab order; Navigate button should come next
clearBtn.setAttribute('aria-label', 'Change account');
clearBtn.innerHTML = '&times;';
clearBtn.addEventListener('click', () => this.clearSelection());
@@ -401,12 +401,24 @@ class SailorTypeahead {
updateFocus(items) {
items.forEach((item, i) => {
item.classList.toggle('focused', i === this.focusIndex);
if (i === this.focusIndex) {
const focused = i === this.focusIndex;
item.classList.toggle('focused', focused);
item.setAttribute('aria-selected', focused ? 'true' : 'false');
if (focused) {
item.scrollIntoView({ block: 'nearest' });
}
});
}
// Clear any pending debounce when the typeahead is torn down (htmx swap
// that removes the input from the DOM). Without this the timer callback
// would still fire and keep the class instance alive on a detached node.
destroy() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
}
async function fetchTypeahead(host, q, timeoutMs) {
@@ -480,9 +492,21 @@ export function saveRecentAccount(handle) {
}
}
document.addEventListener('DOMContentLoaded', () => {
let currentTypeahead = null;
function attachTypeahead() {
const input = document.getElementById('handle');
if (input) {
new SailorTypeahead(input);
if (!input) return;
if (currentTypeahead && currentTypeahead.input === input) return; // already attached to this node
if (currentTypeahead) currentTypeahead.destroy();
currentTypeahead = new SailorTypeahead(input);
}
document.addEventListener('DOMContentLoaded', attachTypeahead);
// Re-attach after htmx swaps — if #handle was inside a swapped region, the
// old instance's debounce timer still references the detached node.
document.body.addEventListener('htmx:afterSettle', attachTypeahead);
document.body.addEventListener('htmx:beforeSwap', () => {
if (currentTypeahead && !document.contains(currentTypeahead.input)) {
currentTypeahead.destroy();
currentTypeahead = null;
}
});
+64 -93
View File
@@ -1,97 +1,67 @@
// Settings page: tab controller + account deletion modal.
// Both initializers are no-ops when their targets aren't on the page.
// Settings page: tablist arrow-key nav + mobile active-tab scroll + account deletion modal.
// Tab switching itself is handled server-side via per-tab URLs; htmx swaps the
// panel without a full page reload. JS here only adds keyboard ergonomics and
// ensures the active tab is visible in the mobile scroll strip on load.
// ----------------------------------------
// Tab controller
// Mobile horizontal tablist (.settings-tab-mobile) and desktop vertical
// sidebar menu (.menu li[data-tab]) stay in sync via a single switch fn.
// Uses roving tabindex + arrow-key nav per WAI-ARIA tabs pattern.
// ----------------------------------------
function initSettingsTabs() {
const validTabs = ['user', 'billing', 'storage', 'devices', 'webhooks', 'advanced'];
if (!document.querySelector('.settings-tab-mobile, .menu li[data-tab]')) return;
const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile'));
if (!sidebarTabs.length && !mobileTabs.length) return;
function switchSettingsTab(tabId) {
document.querySelectorAll('.settings-panel').forEach(p => p.classList.add('hidden'));
const panel = document.getElementById('tab-' + tabId);
if (panel) panel.classList.remove('hidden');
document.querySelectorAll('.menu li[data-tab]').forEach(li => {
const active = li.dataset.tab === tabId;
li.classList.toggle('menu-active', active);
const a = li.querySelector('a[role="tab"]');
if (a) {
a.setAttribute('aria-selected', active ? 'true' : 'false');
a.setAttribute('tabindex', active ? '0' : '-1');
}
});
document.querySelectorAll('.settings-tab-mobile').forEach(btn => {
const active = btn.dataset.tab === tabId;
btn.classList.toggle('btn-ghost', !active);
btn.classList.toggle('btn-secondary', active);
btn.setAttribute('aria-selected', active ? 'true' : 'false');
btn.setAttribute('tabindex', active ? '0' : '-1');
});
history.replaceState(null, '', '#' + tabId);
document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
}
// Exposed so HTMX hx-trigger="every 30s[isTabActive('devices')]" can poll.
window.isTabActive = function(tabId) {
const panel = document.getElementById('tab-' + tabId);
return panel && !panel.classList.contains('hidden');
};
// Exposed so inline <a href="#billing"> onclick can still hop tabs.
window.switchSettingsTab = switchSettingsTab;
function handleTabKeydown(tabs, orientation) {
function bindArrowNav(tabs, orientation) {
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
return function(e) {
const idx = tabs.indexOf(e.currentTarget);
if (idx === -1) return;
let target = null;
if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
else if (e.key === 'Home') target = tabs[0];
else if (e.key === 'End') target = tabs[tabs.length - 1];
if (!target) return;
e.preventDefault();
switchSettingsTab(target.dataset.tab || target.parentElement.dataset.tab);
target.focus();
};
tabs.forEach(tab => {
tab.addEventListener('keydown', e => {
const idx = tabs.indexOf(e.currentTarget);
if (idx === -1) return;
let target = null;
if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
else if (e.key === 'Home') target = tabs[0];
else if (e.key === 'End') target = tabs[tabs.length - 1];
if (!target) return;
e.preventDefault();
target.focus();
target.click();
});
});
}
bindArrowNav(sidebarTabs, 'vertical');
bindArrowNav(mobileTabs, 'horizontal');
const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile'));
const mobileKeydown = handleTabKeydown(mobileTabs, 'horizontal');
mobileTabs.forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
switchSettingsTab(btn.dataset.tab);
function scrollActiveMobileIntoView() {
const activeMobile = mobileTabs.find(t => t.getAttribute('aria-selected') === 'true');
if (activeMobile) activeMobile.scrollIntoView({ inline: 'center', block: 'nearest' });
}
scrollActiveMobileIntoView();
// Sidebar + mobile tablist live outside #tab-content, so htmx swaps don't
// touch their aria-selected / menu-active state. Sync on click.
function setActiveTab(slug) {
sidebarTabs.forEach(a => {
const active = a.parentElement.dataset.tab === slug;
a.setAttribute('aria-selected', active ? 'true' : 'false');
a.setAttribute('tabindex', active ? '0' : '-1');
a.parentElement.classList.toggle('menu-active', active);
});
btn.addEventListener('keydown', mobileKeydown);
mobileTabs.forEach(btn => {
const active = btn.dataset.tab === slug;
btn.setAttribute('aria-selected', active ? 'true' : 'false');
btn.setAttribute('tabindex', active ? '0' : '-1');
btn.classList.toggle('btn-secondary', active);
btn.classList.toggle('btn-ghost', !active);
});
scrollActiveMobileIntoView();
}
[...sidebarTabs, ...mobileTabs].forEach(link => {
link.addEventListener('click', () => setActiveTab(link.dataset.tab || link.parentElement.dataset.tab));
});
const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
const sidebarKeydown = handleTabKeydown(sidebarTabs, 'vertical');
sidebarTabs.forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
switchSettingsTab(link.parentElement.dataset.tab);
});
link.addEventListener('keydown', sidebarKeydown);
});
let hash = window.location.hash.replace('#', '') || 'user';
if (validTabs.indexOf(hash) === -1) hash = 'user';
switchSettingsTab(hash);
window.addEventListener('hashchange', () => {
let h = window.location.hash.replace('#', '') || 'user';
if (validTabs.indexOf(h) !== -1) switchSettingsTab(h);
// Back/forward: htmx's history restore swaps #tab-content; sync tablist from URL.
document.body.addEventListener('htmx:historyRestore', () => {
const m = location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/);
if (m) setActiveTab(m[1]);
});
}
@@ -101,12 +71,12 @@ function initSettingsTabs() {
// module stays pure JS with no server-side string interpolation.
// ----------------------------------------
function initAccountDeletion() {
const deleteBtn = document.getElementById('delete-account-btn');
if (!deleteBtn) return;
const clientShortName = deleteBtn.dataset.clientShortName || 'this account';
const profileHandle = deleteBtn.dataset.profileHandle || '';
const expectedConfirmation = 'DELETE ' + profileHandle;
// Delegated: #delete-account-btn may be swapped in via htmx (advanced tab).
document.addEventListener('click', function(e) {
const deleteBtn = e.target.closest('#delete-account-btn');
if (!deleteBtn) return;
showDeleteConfirmationModal(deleteBtn);
});
function escapeHtml(text) {
const div = document.createElement('div');
@@ -114,9 +84,10 @@ function initAccountDeletion() {
return div.innerHTML;
}
deleteBtn.addEventListener('click', showDeleteConfirmationModal);
function showDeleteConfirmationModal() {
function showDeleteConfirmationModal(deleteBtn) {
const clientShortName = deleteBtn.dataset.clientShortName || 'this account';
const profileHandle = deleteBtn.dataset.profileHandle || '';
const expectedConfirmation = 'DELETE ' + profileHandle;
const deletePDSNow = document.getElementById('delete-pds-records').checked;
const modal = document.createElement('div');
@@ -15,20 +15,22 @@
- .HasMore: bool - whether to show Load More button
*/}}
{{ if .Repositories }}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-6">
<div{{ if .TargetID }} id="{{ .TargetID }}"{{ end }} aria-live="polite" aria-busy="false" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-6">
{{ range .Repositories }}
{{ template "repo-card" . }}
{{ end }}
</div>
{{ if and .HasMore .LoadMoreURL }}
<div class="mt-6 text-center">
{{ if and .HasMore .LoadMoreURL .TargetID }}
<div id="{{ .TargetID }}-lm" class="mt-6 text-center">
<button
class="btn btn-outline"
hx-get="{{ .LoadMoreURL }}"
hx-trigger="click"
hx-target="#{{ .TargetID }}"
hx-swap="beforeend"
hx-target="#{{ .TargetID }}-lm"
hx-swap="outerHTML"
hx-indicator="#{{ .TargetID }}-lm-spinner"
>
<span id="{{ .TargetID }}-lm-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span>
Load More
</button>
</div>
@@ -36,16 +38,48 @@
{{ else }}
<div class="py-12 text-center">
{{ if .EmptyIcon }}
<div class="text-base-content/60 mb-4">
<div class="text-base-content/60">
{{ icon .EmptyIcon "size-12 mx-auto mb-4" }}
<p class="text-lg">{{ or .EmptyMessage "No repositories found." }}</p>
</div>
{{ if .EmptySubtext }}
<p class="text-base-content/70 text-sm">{{ .EmptySubtext }}</p>
{{ end }}
{{ else }}
<p class="text-base-content/60">{{ or .EmptyMessage "No repositories found." }}</p>
{{ end }}
{{ if .EmptySubtext }}
<p class="text-base-content/70 text-sm mt-2">{{ .EmptySubtext }}</p>
{{ end }}
</div>
{{ end }}
{{ end }}
{{/*
card-grid-append — response fragment for Load More pagination.
Emits the new page's cards OOB-swapped into #{{ .TargetID }} and
replaces the existing #{{ .TargetID }}-lm button wrapper via the
primary outerHTML swap. When .HasMore is false, the button wrapper
is replaced with an empty div, removing the Load More control.
*/}}
{{ define "card-grid-append" }}
<div hx-swap-oob="beforeend:#{{ .TargetID }}">
{{ range .Repositories }}
{{ template "repo-card" . }}
{{ end }}
</div>
{{ if and .HasMore .LoadMoreURL }}
<div id="{{ .TargetID }}-lm" class="mt-6 text-center">
<button
class="btn btn-outline"
hx-get="{{ .LoadMoreURL }}"
hx-trigger="click"
hx-target="#{{ .TargetID }}-lm"
hx-swap="outerHTML"
hx-indicator="#{{ .TargetID }}-lm-spinner"
>
<span id="{{ .TargetID }}-lm-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span>
Load More
</button>
</div>
{{ else }}
<div id="{{ .TargetID }}-lm"></div>
{{ end }}
{{ end }}
@@ -8,7 +8,7 @@
<div class="cmd group">
{{ icon "terminal" "size-4 shrink-0 text-base-content/60" }}
<code>{{ . }}</code>
<button class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ . }}" aria-label="Copy command to clipboard">
<button class="cmd-copy btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2" data-cmd="{{ . }}" aria-label="Copy command to clipboard">
{{ icon "copy" "size-4" }}
</button>
</div>
@@ -24,10 +24,10 @@
- Display: string - short form shown in the UI (e.g. "alice.bsky.social/myapp:v1.2.3")
- Copy: string - full command copied to clipboard (e.g. "docker pull atcr.io/alice.bsky.social/myapp:v1.2.3")
*/}}
<div class="cmd group !w-full">
<div class="cmd group w-full!">
{{ icon "terminal" "size-4 shrink-0 text-base-content/60" }}
<code>{{ .Display }}</code>
<button class="btn btn-ghost btn-xs absolute right-1 top-1/2 -translate-y-1/2 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ .Copy }}" aria-label="Copy pull command to clipboard">
<button class="cmd-copy btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2" data-cmd="{{ or .Copy .Display }}" aria-label="Copy pull command to clipboard">
{{ icon "copy" "size-4" }}
</button>
</div>
+11 -9
View File
@@ -1,21 +1,23 @@
{{ define "footer" }}
<footer class="footer footer-center bg-base-200 text-base-content p-6 pt-20 mt-auto relative">
<img src="/static/wave-pattern.svg" alt="" width="1440" height="64" loading="lazy" decoding="async" class="absolute top-0 left-0 w-full h-16 pointer-events-none rotate-180" aria-hidden="true">
<img src="/static/wave-pattern.svg" alt="" width="1440" height="64" decoding="async" class="absolute top-0 left-0 w-full h-16 pointer-events-none rotate-180" aria-hidden="true">
<nav class="flex flex-wrap justify-center items-center gap-x-2 gap-y-1 text-sm">
<a href="/privacy" class="link link-hover">Privacy</a>
<span class="text-base-content/30">·</span>
<span aria-hidden="true" class="text-base-content/30">·</span>
<a href="/terms" class="link link-hover">Terms</a>
<span class="text-base-content/30">·</span>
<a href="https://bsky.app/profile/atcr.io" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
<svg class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 568 501"><path fill="currentColor" d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
<span aria-hidden="true" class="text-base-content/30">·</span>
<a href="https://bsky.app/profile/atcr.io" target="_blank" rel="noopener" aria-label="{{ .ClientShortName }} on Bluesky" class="link link-hover inline-flex items-center gap-1">
<svg aria-hidden="true" class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 568 501"><path fill="currentColor" d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
Bluesky
</a>
<span class="text-base-content/30">·</span>
<a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
<img src="/static/tangled-black.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-light">
<img src="/static/tangled-white.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-dark">
{{ with .SourceURL }}
<span aria-hidden="true" class="text-base-content/30">·</span>
<a href="{{ . }}" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
<img src="/static/tangled-black.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-light" aria-hidden="true">
<img src="/static/tangled-white.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-dark" aria-hidden="true">
Source
</a>
{{ end }}
</nav>
</footer>
{{ end }}
+28 -13
View File
@@ -1,6 +1,8 @@
{{ define "head" }}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark light">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="theme-color" id="theme-color">
<!-- Favicons -->
@@ -14,12 +16,12 @@
<link rel="preconnect" href="https://imgs.blue" crossorigin>
<link rel="dns-prefetch" href="https://imgs.blue">
<!-- Preload critical assets. Onest is the display face used on h1/hero
headings and is the LCP candidate on landing/discovery pages. -->
<!-- Preload critical assets. Font list is discovered from the public FS
at startup so renaming a .woff2 doesn't silently 404. -->
<link rel="preload" href="/icons.svg" as="image" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/figtree-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/commit-mono-400.woff2" as="font" type="font/woff2" crossorigin>
{{ range fontPreloads }}
<link rel="preload" href="{{ . }}" as="font" type="font/woff2" crossorigin>
{{ end }}
<!-- Theme: apply early to prevent flash -->
<script>
@@ -32,10 +34,25 @@
function updateThemeColor() {
var meta = document.getElementById('theme-color');
if (meta) {
var bg = getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim();
if (bg) meta.setAttribute('content', bg);
if (!meta) return;
var bg = getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim();
if (bg) {
meta.setAttribute('content', bg);
return true;
}
return false;
}
// On cold load the stylesheet may not be parsed when this script
// runs, so --color-base-100 resolves empty. Retry on rAF until we
// get a real value, and again on the load event as a final safety
// net. Also re-run on system preference changes.
function scheduleThemeColorUpdate() {
if (updateThemeColor()) return;
requestAnimationFrame(function() {
if (updateThemeColor()) return;
window.addEventListener('load', updateThemeColor, { once: true });
});
}
var pref = localStorage.getItem('theme') || 'system';
@@ -43,15 +60,13 @@
document.documentElement.classList.toggle('dark', effective === 'dark');
document.documentElement.setAttribute('data-theme', effective);
// Update theme-color after styles are applied
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateThemeColor);
document.addEventListener('DOMContentLoaded', scheduleThemeColorUpdate);
} else {
updateThemeColor();
scheduleThemeColorUpdate();
}
// Also update when system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColor);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', scheduleThemeColorUpdate);
})();
</script>
+6 -6
View File
@@ -4,9 +4,9 @@
*/}}
<section class="hero bg-base-200 min-h-[60vh] py-16 pb-24 relative overflow-hidden">
<div class="hero-content text-center flex-col relative z-10 w-full">
<h1 class="text-4xl md:text-5xl font-display font-bold tracking-tight">your registry <span class="text-primary">at</span> sea.</h1>
<p class="text-lg text-base-content/70 max-w-lg mt-4">
Push and pull Docker images on the AT Protocol.<br>
<h1 class="text-4xl md:text-5xl font-display font-bold tracking-tight text-balance">your registry <span class="text-primary">at</span> sea.</h1>
<p class="text-lg text-base-content/70 max-w-lg mt-4 text-balance">
Push and pull Docker images on the AT Protocol.
Browse public registries or control your data.
</p>
@@ -23,21 +23,21 @@
<!-- Benefit Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl">
<div class="card bg-base-200 shadow-sm p-6 text-center">
<div class="card bg-base-100 border border-base-300 p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
{{ icon "ship" "size-8" }}
</div>
<h2 class="font-semibold text-lg">Works with Docker</h2>
<p class="text-base-content/70 mt-2">Use docker push &amp; pull. No new tools to learn.</p>
</div>
<div class="card bg-base-200 shadow-sm p-6 text-center">
<div class="card bg-base-100 border border-base-300 p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
{{ icon "anchor" "size-8" }}
</div>
<h2 class="font-semibold text-lg">Your Data</h2>
<p class="text-base-content/70 mt-2">Join shared holds or captain your own storage.</p>
</div>
<div class="card bg-base-200 shadow-sm p-6 text-center">
<div class="card bg-base-100 border border-base-300 p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
{{ icon "compass" "size-8" }}
</div>
+13 -9
View File
@@ -1,30 +1,34 @@
{{ define "meta" }}
{{/* Title */}}
<title>{{ .Title }}</title>
{{/* Title falls back to SiteName if empty — better than rendering a blank
<title> that screen readers and tab chrome handle poorly. */}}
<title>{{ or .Title .SiteName "ATCR" }}</title>
{{/* Basic meta */}}
<meta name="description" content="{{ .Description }}">
{{/* Description: omit the tag entirely when empty rather than emitting
<meta name="description" content="">, which some SEO tools flag. */}}
{{ if .Description }}<meta name="description" content="{{ .Description }}">{{ end }}
{{ if .Canonical }}<link rel="canonical" href="{{ .Canonical }}">{{ end }}
{{ if .Robots }}<meta name="robots" content="{{ .Robots }}">{{ end }}
{{/* OpenGraph */}}
<meta property="og:locale" content="en_US">
<meta property="og:title" content="{{ .Title }}">
<meta property="og:description" content="{{ .Description }}">
<meta property="og:locale" content="{{ or .OGLocale "en_US" }}">
<meta property="og:title" content="{{ or .Title .SiteName "ATCR" }}">
{{ if .Description }}<meta property="og:description" content="{{ .Description }}">{{ end }}
<meta property="og:type" content="{{ or .OGType "website" }}">
{{ if .Canonical }}<meta property="og:url" content="{{ .Canonical }}">{{ end }}
{{ if .OGImage }}
<meta property="og:image" content="{{ .OGImage }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{ if .OGImageAlt }}<meta property="og:image:alt" content="{{ .OGImageAlt }}">{{ end }}
{{ end }}
<meta property="og:site_name" content="{{ or .SiteName "ATCR" }}">
{{/* Twitter Card */}}
<meta name="twitter:card" content="{{ or .TwitterCard "summary_large_image" }}">
<meta name="twitter:title" content="{{ .Title }}">
<meta name="twitter:description" content="{{ .Description }}">
<meta name="twitter:title" content="{{ or .Title .SiteName "ATCR" }}">
{{ if .Description }}<meta name="twitter:description" content="{{ .Description }}">{{ end }}
{{ if .OGImage }}<meta name="twitter:image" content="{{ .OGImage }}">{{ end }}
{{ if and .OGImage .OGImageAlt }}<meta name="twitter:image:alt" content="{{ .OGImageAlt }}">{{ end }}
{{/* JSON-LD */}}
{{ range .JSONLD }}
@@ -1,30 +0,0 @@
{{ define "manifest-modal" }}
<dialog class="modal modal-open" data-action="modal-backdrop-close">
<div class="modal-box bg-base-200">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" data-action="remove-closest-dialog" aria-label="Close manifest details"></button>
<h2 class="text-xl font-semibold mb-4">Manifest Details</h2>
<dl class="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-3 text-sm">
<dt class="text-base-content/70 font-medium">Digest</dt>
<dd class="font-mono break-all">{{ .Digest }}</dd>
<dt class="text-base-content/70 font-medium">Media Type</dt>
<dd class="break-all">{{ .MediaType }}</dd>
<dt class="text-base-content/70 font-medium">Hold Endpoint</dt>
<dd class="break-all">{{ .HoldEndpoint }}</dd>
<dt class="text-base-content/70 font-medium">Created</dt>
<dd>
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }}
</time>
</dd>
</dl>
</div>
<form method="dialog" class="modal-backdrop">
<button data-action="remove-closest-dialog">close</button>
</form>
</dialog>
{{ end }}
@@ -1,6 +1,6 @@
{{ define "nav-brand" }}
<a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-secondary no-underline tracking-tight">
<img src="/favicon-96x96.png" width="48" height="48" fetchpriority="high" class="h-12 w-auto" alt="{{ .ClientName }} logo">
{{ .ClientName }}
<a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-secondary no-underline tracking-tight focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded-sm">
<img src="/favicon-96x96.png" width="48" height="48" fetchpriority="high" class="h-12 w-auto" alt="" aria-hidden="true">
<span class="min-w-0 max-w-56 truncate sm:max-w-none">{{ .ClientName }}</span>
</a>
{{ end }}
@@ -3,23 +3,23 @@
<summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings" aria-haspopup="menu">
<svg class="icon size-5" data-theme-icon aria-hidden="true"><use href="/icons.svg#sun"></use></svg>
</summary>
<ul data-theme-menu role="menu" class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<ul data-theme-menu role="group" aria-label="Select theme" class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<li role="none">
<button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="system">
<button type="button" role="radio" aria-checked="false" class="theme-option" data-value="system">
{{ icon "sun-moon" "size-4" }}
<span>System</span>
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
</button>
</li>
<li role="none">
<button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="light">
<button type="button" role="radio" aria-checked="false" class="theme-option" data-value="light">
{{ icon "sun" "size-4" }}
<span>Light</span>
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
</button>
</li>
<li role="none">
<button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="dark">
<button type="button" role="radio" aria-checked="false" class="theme-option" data-value="dark">
{{ icon "moon" "size-4" }}
<span>Dark</span>
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
@@ -27,4 +27,21 @@
</li>
</ul>
</details>
<script>
// Sync aria-checked on the theme radios before JS has a chance to hydrate
// the full menu. Without this, screen readers pre-hydration announce all
// three options as "not selected." Runs inline so it executes right after
// the menu renders.
(function() {
try {
var pref = localStorage.getItem('theme') || 'system';
document.querySelectorAll('.theme-option').forEach(function(btn) {
var on = btn.dataset.value === pref;
btn.setAttribute('aria-checked', on ? 'true' : 'false');
var check = btn.querySelector('.theme-check');
if (check) check.classList.toggle('invisible', !on);
});
} catch (e) { /* localStorage unavailable; leave defaults */ }
})();
</script>
{{ end }}
@@ -1,19 +1,22 @@
{{ define "nav-user" }}
{{ if .User }}
<details class="dropdown dropdown-end">
<summary class="btn btn-ghost gap-2 list-none" aria-label="User menu">
<summary class="btn btn-ghost gap-2 list-none focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" aria-label="User menu" aria-haspopup="menu">
<div class="avatar{{ if not .User.Avatar }} avatar-placeholder{{ end }}">
{{ if .User.Avatar }}
<div class="w-7 rounded-full">
<img src="{{ resizeImage .User.Avatar 96 }}" alt="{{ .User.Handle }}" width="28" height="28" />
<div class="w-7 rounded-full bg-secondary text-secondary-content flex items-center justify-center relative">
<span aria-hidden="true" class="text-xs">{{ firstChar .User.Handle }}</span>
<img src="{{ resizeImage .User.Avatar 96 }}" alt="" aria-hidden="true" width="28" height="28"
class="absolute inset-0 w-full h-full rounded-full object-cover"
onerror="this.remove()" />
</div>
{{ else }}
<div class="bg-secondary text-secondary-content w-7 rounded-full">
<span class="text-xs">{{ firstChar .User.Handle }}</span>
<span aria-hidden="true" class="text-xs">{{ firstChar .User.Handle }}</span>
</div>
{{ end }}
</div>
<span class="hidden sm:inline">@{{ .User.Handle }}</span>
<span class="hidden sm:inline truncate max-w-48">@{{ .User.Handle }}</span>
{{ icon "chevron-down" "size-3.5" }}
</summary>
<ul class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-52 p-2 shadow-lg">
@@ -26,6 +29,6 @@
<form id="logout-form" action="/auth/logout" method="POST" hidden></form>
</details>
{{ else }}
<a href="/auth/oauth/login?return_to=/" class="btn btn-secondary btn-sm">Login</a>
<a href="/auth/oauth/login?return_to={{ urlquery (or .CurrentPath "/") }}" class="btn btn-secondary btn-sm">Login</a>
{{ end }}
{{ end }}
+2 -2
View File
@@ -4,7 +4,7 @@
{{ define "nav" }}
{{ template "skip-link" }}
<nav class="navbar bg-base-200 text-secondary px-4">
<nav aria-label="Primary" class="navbar bg-base-200 px-4">
<div class="navbar-start">
{{ template "nav-brand" . }}
</div>
@@ -18,7 +18,7 @@
{{ define "nav-simple" }}
{{ template "skip-link" }}
<nav class="navbar bg-base-200 text-secondary px-4">
<nav aria-label="Primary" class="navbar bg-base-200 px-4">
<div class="navbar-start">
{{ template "nav-brand" . }}
</div>
@@ -34,7 +34,7 @@
<option value="crane"{{ if eq .OciClient "crane" }} selected{{ end }}>crane</option>
<option value="none"{{ if eq .OciClient "none" }} selected{{ end }}>image ref only</option>
</select>
<div id="pull-cmd-display" class="flex-1 min-w-0">
<div id="pull-cmd-display" class="flex-1 min-w-0" aria-live="polite">
{{ if .Tag }}
{{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag) }}
{{ else }}
@@ -3,8 +3,9 @@
Pull count component - displays download icon with count
Required: .PullCount (int)
*/}}
<span class="flex items-center gap-2 text-base-content/60">
<span class="flex items-center gap-2 text-base-content/60" title="{{ .PullCount }} pulls">
{{ icon "arrow-down-to-line" "size-[1.1rem] text-primary" }}
<span class="font-semibold text-base-content">{{ .PullCount }}</span>
<span class="font-semibold text-base-content">{{ humanizeCount .PullCount }}</span>
<span class="sr-only">pulls</span>
</span>
{{ end }}
@@ -8,14 +8,14 @@
{{ if .IconURL }}
<img src="{{ resizeImage .IconURL 160 }}" alt="{{ .RepositoryName }}" width="80" height="80" fetchpriority="high" class="w-20 rounded-lg object-cover">
{{ else }}
<div class="avatar avatar-placeholder">
<div class="avatar avatar-placeholder" role="img" aria-label="Avatar for {{ .RepositoryName }}">
<div class="bg-neutral text-neutral-content w-20 rounded-lg shadow-sm uppercase">
<span class="text-4xl">{{ firstChar .RepositoryName }}</span>
<span aria-hidden="true" class="text-4xl">{{ firstChar .RepositoryName }}</span>
</div>
</div>
{{ end }}
{{ if .IsOwner }}
<label class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity cursor-pointer rounded-lg" for="avatar-upload" aria-label="Upload repository icon">
<label class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 focus-within:opacity-100 transition-opacity cursor-pointer rounded-lg" for="avatar-upload" aria-label="Upload repository icon">
{{ icon "plus" "size-8 text-neutral-content" }}
</label>
<input type="hidden" id="avatar-repo" name="repo" value="{{ .RepositoryName }}">
@@ -26,6 +26,7 @@
hx-encoding="multipart/form-data"
hx-swap="outerHTML"
hx-target="#repo-avatar"
hx-on::before-swap="if(!event.detail.successful) event.detail.shouldSwap=false"
hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'"
class="hidden">
{{ end }}
@@ -32,10 +32,10 @@
</div>
{{ end }}
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm flex items-baseline gap-1 min-w-0">
<a href="/u/{{ .OwnerHandle }}" class="link link-primary truncate min-w-0">{{ .OwnerHandle }}</a>
<span class="text-base-content/60 shrink-0">/</span>
<span class="text-base-content truncate min-w-0">{{ .Repository }}</span>
<div class="font-semibold text-sm flex items-baseline gap-1 min-w-0" aria-label="{{ .Repository }} by {{ .OwnerHandle }}">
<a href="/u/{{ .OwnerHandle }}" tabindex="-1" class="link link-primary truncate min-w-0 max-w-[50%]">{{ .OwnerHandle }}</a>
<span class="text-base-content/60 shrink-0" aria-hidden="true">/</span>
<span class="text-base-content truncate min-w-0 flex-1">{{ .Repository }}</span>
</div>
{{ if .Tag }}
<span class="block text-base-content/60 text-sm truncate">Tag: {{ .Tag }}</span>
@@ -73,11 +73,14 @@
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }}
{{ template "pull-count" (dict "PullCount" .PullCount) }}
{{ if eq .ArtifactType "helm-chart" }}
{{ icon "helm" "size-5 text-helm" }}
<span class="inline-flex items-center">
{{ icon "helm" "size-5 text-helm" }}
<span class="sr-only">Helm chart</span>
</span>
{{ end }}
</div>
{{ if not .LastUpdated.IsZero }}
<span class="text-base-content/60 text-sm flex items-center gap-1">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span>
<span class="text-base-content/60 text-sm flex items-center gap-1" title="{{ humanizeTime .LastUpdated }}">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span>
{{ end }}
</div>
</article>
+18 -6
View File
@@ -8,26 +8,38 @@
Display mode: renders as span (default)
*/}}
{{ if .Interactive }}
{{ if .IsAuthenticated }}
<button class="btn btn-sm gap-2 btn-ghost group border border-transparent hover:border-primary{{ if .IsStarred }} border-star!{{ end }}"
id="star-btn"
hx-ext="json-enc"
{{ if .IsStarred }}
hx-delete="/api/stars"
{{ else }}
hx-post="/api/stars"
{{ end }}
hx-vals='{"handle": "{{ .Handle }}", "repo": "{{ .Repository }}"}'
hx-vals='{{ dict "handle" .Handle "repo" .Repository | toJSON }}'
hx-target="this"
hx-swap="outerHTML"
hx-on::before-request="this.disabled=true"
hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'"
hx-on::after-request="if(event.detail.xhr.status===401){window.location='/auth/oauth/login'}else if(!event.detail.successful){this.disabled=false}"
aria-label="{{ if .IsStarred }}Unstar{{ else }}Star{{ end }} {{ .Handle }}/{{ .Repository }}">
<svg class="icon size-4 text-star stroke-star transition-transform group-hover:scale-110{{ if .IsStarred }} fill-star!{{ end }}" id="star-icon" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<span id="star-count">{{ .StarCount }}</span>
<svg class="icon size-4 text-star stroke-star transition-transform group-hover:scale-110{{ if .IsStarred }} fill-star!{{ end }}" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<span>{{ .StarCount }}</span>
</button>
{{ else }}
<span class="flex items-center gap-2 text-base-content/60">
{{/* Anon viewer: link to login so the click isn't an abrupt 401 redirect. */}}
<a href="/auth/oauth/login?return_to=/r/{{ .Handle }}/{{ .Repository }}"
class="btn btn-sm gap-2 btn-ghost border border-transparent hover:border-primary"
title="Sign in to star this repository"
aria-label="Sign in to star {{ .Handle }}/{{ .Repository }}">
<svg class="icon size-4 text-star stroke-star" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<span>{{ .StarCount }}</span>
</a>
{{ end }}
{{ else }}
<span class="flex items-center gap-2 text-base-content/60" title="{{ .StarCount }} stars">
<svg class="icon size-[1.1rem] text-star stroke-star{{ if .IsStarred }} fill-star!{{ end }}" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<span class="font-semibold text-base-content">{{ .StarCount }}</span>
<span class="sr-only">stars</span>
</span>
{{ end }}
{{ end }}
+1 -1
View File
@@ -6,7 +6,7 @@
{{ template "meta" .Meta }}
</head>
<body>
{{ template "nav-simple" . }}
{{ if .User }}{{ template "nav" . }}{{ else }}{{ template "nav-simple" . }}{{ end }}
<main id="main-content" class="hero min-h-[60vh]">
<div class="hero-content text-center">
<div class="flex flex-col items-center">
+24 -14
View File
@@ -19,13 +19,23 @@
</ul>
</div>
{{ 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 }}
<!-- Summary Card -->
<div class="card bg-base-200 shadow-sm border border-base-300 p-6">
<div class="flex flex-wrap items-center gap-2 mb-4">
<h1 class="text-xl font-bold">
<span class="font-mono">{{ .FromTag }}</span>
<span class="text-base-content/40 mx-1"></span>
<span class="font-mono">{{ .ToTag }}</span>
<h1 class="text-xl font-bold wrap-break-word min-w-0">
<span class="whitespace-nowrap">
<span class="font-mono inline-block align-baseline max-w-[24ch] truncate" title="{{ .FromTag }}">{{ .FromTag }}</span>
<span class="text-base-content/40 mx-1" aria-hidden="true"></span>
</span>
<span class="font-mono inline-block align-baseline max-w-[24ch] truncate" title="{{ .ToTag }}">{{ .ToTag }}</span>
</h1>
</div>
@@ -47,12 +57,12 @@
{{ if gt .Summary.VulnFixedCount 0 }}
<div class="stat bg-success/10 rounded-lg p-3">
<div class="stat-title text-xs">Fixed</div>
<div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}</div>
<div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} {{ pluralize .Summary.VulnFixedCount "vuln" "vulns" }}</div>
<div class="stat-desc text-xs">
{{ 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 }}<span class="sr-only"> Critical</span><span aria-hidden="true">C</span> {{ end }}
{{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}<span class="sr-only"> High</span><span aria-hidden="true">H</span> {{ end }}
{{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}<span class="sr-only"> Medium</span><span aria-hidden="true">M</span> {{ end }}
{{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}<span class="sr-only"> Low</span><span aria-hidden="true">L</span>{{ end }}
</div>
</div>
{{ end }}
@@ -61,12 +71,12 @@
{{ if gt .Summary.VulnNewCount 0 }}
<div class="stat bg-error/10 rounded-lg p-3">
<div class="stat-title text-xs">New</div>
<div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}</div>
<div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} {{ pluralize .Summary.VulnNewCount "vuln" "vulns" }}</div>
<div class="stat-desc text-xs">
{{ 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 }}<span class="sr-only"> Critical</span><span aria-hidden="true">C</span> {{ end }}
{{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}<span class="sr-only"> High</span><span aria-hidden="true">H</span> {{ end }}
{{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}<span class="sr-only"> Medium</span><span aria-hidden="true">M</span> {{ end }}
{{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}<span class="sr-only"> Low</span><span aria-hidden="true">L</span>{{ end }}
</div>
</div>
{{ end }}
+12 -7
View File
@@ -26,7 +26,7 @@
<!-- Title: tags or truncated digest -->
<div class="flex flex-wrap items-center gap-2">
{{ if .Manifest.Tags }}
<h1 class="text-xl font-bold">{{ 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 }} <span class="text-sm font-normal text-base-content/60" title="{{ range $i, $tag := .Manifest.Tags }}{{ if $i }}, {{ end }}{{ $tag }}{{ end }}">+{{ sub (len .Manifest.Tags) 3 }} more</span>{{ end }}</h1>
<h1 class="text-xl font-bold flex flex-wrap gap-x-1 items-center min-w-0">{{ range $i, $tag := .Manifest.Tags }}{{ if lt $i 3 }}{{ if $i }}<span aria-hidden="true">,</span>{{ end }}<span class="inline-block max-w-[24ch] truncate align-baseline" title="{{ $tag }}">{{ $tag }}</span>{{ end }}{{ end }}{{ if gt (len .Manifest.Tags) 3 }}<span class="text-sm font-normal text-base-content/60" title="{{ range $i, $tag := .Manifest.Tags }}{{ if $i }}, {{ end }}{{ $tag }}{{ end }}">+{{ sub (len .Manifest.Tags) 3 }} more</span>{{ end }}</h1>
{{ else }}
<h1 class="text-xl font-bold font-mono" title="{{ .Manifest.Digest }}">{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}</h1>
{{ end }}
@@ -46,7 +46,9 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-base-content text-sm flex items-center gap-1" title="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span>
{{ if not .Manifest.CreatedAt.IsZero }}
<span class="text-base-content text-sm flex items-center gap-1" title="{{ humanizeTime .Manifest.CreatedAt }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span>
{{ end }}
</div>
</div>
@@ -73,25 +75,28 @@
</div>
<!-- Upgrade Banner (HTMX lazy-loaded) -->
<div id="upgrade-banner"
hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ .Manifest.HoldEndpoint }}{{ end }}"
<div id="upgrade-banner" role="status" aria-live="polite"
hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ urlquery .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ urlquery .Manifest.HoldEndpoint }}{{ end }}"
hx-trigger="load"
hx-swap="innerHTML">
</div>
<!-- Content: Layers + Vulnerabilities -->
<div id="digest-content">
<div id="digest-content" aria-live="polite" aria-busy="false">
{{ if .Manifest.IsManifestList }}
<!-- Auto-load selected platform -->
{{ if .Manifest.Platforms }}
<div hx-get="/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .SelectedPlatform }}"
<!-- Auto-load selected platform -->
<div hx-get="/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ urlquery .SelectedPlatform }}"
hx-trigger="load"
hx-target="#digest-content"
hx-swap="innerHTML"
hx-on::response-error="this.innerHTML='<div class=&quot;alert alert-error&quot;>We couldn&rsquo;t load this platform. Try refreshing.</div>'"
class="flex items-center justify-center py-12">
{{ icon "loader" "size-6 animate-spin text-base-content/40" }}
<span class="ml-2 text-base-content/60">Loading layers and vulnerabilities...</span>
</div>
{{ else }}
<p class="py-12 text-center text-base-content/60">No platform manifests found for this image index.</p>
{{ end }}
{{ else }}
{{ template "digest-content" . }}
+32 -1
View File
@@ -16,10 +16,15 @@
<div class="space-y-12">
<!-- Featured Repositories Section -->
{{ if .FeaturedRepos }}
<section>
<section aria-roledescription="carousel" aria-label="Featured repositories">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">Featured</h2>
{{ if gt (len .FeaturedRepos) 1 }}
<div class="flex gap-2">
<button id="carousel-pause" class="btn btn-circle btn-ghost btn-sm" aria-label="Pause carousel auto-advance" aria-pressed="false">
{{ icon "pause" "size-5 carousel-pause-icon" }}
{{ icon "play" "size-5 carousel-play-icon hidden" }}
</button>
<button id="carousel-prev" class="btn btn-circle btn-ghost btn-sm" aria-label="Previous featured repository">
{{ icon "chevron-left" "size-5" }}
</button>
@@ -27,6 +32,7 @@
{{ icon "chevron-right" "size-5" }}
</button>
</div>
{{ end }}
</div>
<div id="featured-carousel" class="carousel w-full gap-3 sm:gap-6 scroll-smooth">
{{ range $i, $repo := .FeaturedRepos }}
@@ -45,6 +51,31 @@
{{ template "card-grid" (dict "Repositories" .RecentRepos) }}
</section>
{{ 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 }}
<div class="text-center py-16 text-base-content/60">
{{ if .User }}
{{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }}
<p class="text-lg font-medium text-base-content/70">Nothing here yet</p>
<p class="mt-2 text-sm">Push your first image to get started.</p>
{{ 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" }}
<p class="text-lg font-medium text-base-content/70">No public repositories yet</p>
<p class="mt-2 text-sm">Be the first to push an image.</p>
{{ end }}
</div>
{{ 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 }}
</div>
</main>
+11 -7
View File
@@ -16,12 +16,16 @@
<section>
<h2 class="text-xl font-semibold mb-4">Quick Install</h2>
<div class="flex gap-2 mb-4">
<button type="button" class="btn btn-sm btn-primary platform-tab" data-platform="linux">Linux / macOS</button>
<button type="button" class="btn btn-sm btn-ghost platform-tab" data-platform="windows">Windows</button>
<div class="flex gap-2 mb-4" role="tablist" aria-label="Platform">
<button type="button" role="tab" id="linux-tab"
aria-selected="true" aria-controls="linux-content"
class="btn btn-sm btn-primary platform-tab" data-platform="linux">Linux / macOS</button>
<button type="button" role="tab" id="windows-tab"
aria-selected="false" aria-controls="windows-content" tabindex="-1"
class="btn btn-sm btn-ghost platform-tab" data-platform="windows">Windows</button>
</div>
<div class="platform-content" id="linux-content">
<div class="platform-content" id="linux-content" role="tabpanel" aria-labelledby="linux-tab">
<h3 class="text-lg font-medium mb-3">Using install script</h3>
<div class="mockup-code bg-base-300 text-base-content mb-6">
<pre data-prefix="$"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre>
@@ -35,7 +39,7 @@
</div>
</div>
<div class="platform-content hidden" id="windows-content">
<div class="platform-content hidden" id="windows-content" role="tabpanel" aria-labelledby="windows-tab" hidden>
<h3 class="text-lg font-medium mb-3">Using PowerShell (Run as Administrator)</h3>
<div class="mockup-code bg-base-300 text-base-content mb-6">
<pre data-prefix="PS"><code>iwr -useb {{ .SiteURL }}/static/install.ps1 | iex</code></pre>
@@ -95,7 +99,7 @@
<p class="mb-4">You can also use <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">docker login</code> with your ATProto app password:</p>
<ol class="list-decimal list-inside space-y-2 ml-4 mb-6">
<li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">bsky.app/settings/app-passwords</a></li>
<li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer" class="link link-primary">bsky.app/settings/app-passwords</a></li>
<li>Run: <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">docker login {{ .RegistryURL }}</code></li>
<li>Enter your handle as username</li>
<li>Enter your app password</li>
@@ -103,7 +107,7 @@
<div class="alert alert-info">
{{ icon "info" "size-5" }}
<span>Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline font-medium hover:no-underline">bsky.app/settings/app-passwords</a>.</span>
<span>Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer" class="underline font-medium hover:no-underline">bsky.app/settings/app-passwords</a>.</span>
</div>
</section>
+5 -3
View File
@@ -164,11 +164,13 @@
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="/install" class="btn btn-primary btn-lg">Get Started</a>
<a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="btn btn-ghost btn-lg">
<img src="/static/tangled-black.svg" alt="" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-light">
<img src="/static/tangled-white.svg" alt="" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-dark">
{{ with .SourceURL }}
<a href="{{ . }}" target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-lg">
<img src="/static/tangled-black.svg" alt="" aria-hidden="true" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-light">
<img src="/static/tangled-white.svg" alt="" aria-hidden="true" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-dark">
View Source
</a>
{{ end }}
</div>
</section>
</main>
+16 -3
View File
@@ -17,7 +17,19 @@
{{ icon "circle-x" "size-5" }}
<span>
{{ 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 @@
</div>
{{ end }}
<form action="/auth/oauth/login" method="POST" id="login-form" class="max-w-md mx-auto flex flex-col">
<form action="/auth/oauth/login" method="POST" id="login-form" class="max-w-md mx-auto flex flex-col"
onsubmit="var b=this.querySelector('button[type=submit]');if(b){b.disabled=true;b.textContent='Signing in\u2026';}">
<input type="hidden" name="return_to" value="{{ .ReturnTo }}" />
<div class="sailor-typeahead relative order-1">
@@ -46,7 +59,7 @@
{{ if .Error }}aria-invalid="true" aria-describedby="login-error"{{ end }} />
</div>
<button type="submit" class="btn btn-primary btn-lg w-full mt-6 order-3">
<button type="submit" aria-label="Sign in" class="btn btn-primary btn-lg w-full mt-6 order-3">
Navigate
</button>
+6 -10
View File
@@ -9,8 +9,8 @@
{{ template "nav" . }}
<main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-3xl font-display font-bold tracking-tight mb-2">Privacy Policy - {{ .CompanyName }} ({{ .SiteURL }})</h1>
<p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
<h1 class="text-3xl font-display font-bold tracking-tight mb-2 wrap-break-word">Privacy Policy &mdash; {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}</h1>
<p class="text-base-content/60 mb-8"><em>Last updated: {{ .LastUpdated }}</em></p>
<div class="prose prose-sm max-w-none space-y-8">
<section>
@@ -321,16 +321,12 @@
<p class="mt-2">Please include your AT Protocol DID or handle so we can verify your identity.</p>
<p class="mt-2">We will respond to requests within 30 days (GDPR) or 45 days (CCPA).</p>
{{ with .Jurisdiction }}
<p class="mt-4 text-sm text-base-content/70">This policy is governed by the laws of {{ . }}.</p>
{{ end }}
</section>
<section>
<h2 class="text-xl font-semibold text-primary">Contact</h2>
<p>For questions about this privacy policy or to exercise your data rights, contact:</p>
<p class="mt-4"><strong>Email:</strong> <a href="mailto:privacy@{{ .SiteURL }}" class="link link-primary">privacy@{{ .SiteURL }}</a></p>
<p><strong>Website:</strong> <a href="https://{{ .SiteURL }}" class="link link-primary">https://{{ .SiteURL }}</a></p>
</section>
</div>
</main>
+48 -24
View File
@@ -15,13 +15,13 @@
<div class="flex gap-4 items-start">
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
<div class="flex-1 min-w-0">
<h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight">
<h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight wrap-break-word min-w-0">
<a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a>
<span class="text-base-content/60">/</span>
<span class="text-base-content/60" aria-hidden="true">/</span>
<span>{{ .Repository.Name }}</span>
</h1>
{{ if .Repository.Description }}
<p class="text-base-content/70 mt-2">{{ .Repository.Description }}</p>
<p class="text-base-content/70 mt-2 line-clamp-3 wrap-break-word">{{ .Repository.Description }}</p>
{{ end }}
</div>
</div>
@@ -29,14 +29,22 @@
<!-- Metadata Row -->
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-3">
{{ 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. */}}
<span class="text-sm text-base-content/50" title="Star and pull counts are temporarily unavailable">
{{ icon "alert-circle" "size-4 inline" }} Stats unavailable
</span>
{{ end }}
{{ if .TagCount }}
<span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} tags">
<span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} {{ pluralize .TagCount "tag" "tags" }}">
{{ icon "tag" "size-4" }} {{ .TagCount }}
</span>
{{ end }}
{{ if .Stats.LastPush }}
{{ if and .StatsAvailable .Stats.LastPush }}
<span class="text-sm text-base-content/70" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}">
Updated {{ timeAgoShort (derefTime .Stats.LastPush) }}
</span>
@@ -57,7 +65,7 @@
{{ .SPDXID }}
</a>
{{ else }}
<span class="badge badge-md badge-soft badge-secondary" title="Custom license: {{ .Name }}">
<span class="badge badge-md badge-soft badge-secondary max-w-48 truncate" title="Custom license: {{ .Name }}">
{{ .Name }}
</span>
{{ end }}
@@ -83,25 +91,30 @@
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
{{ icon "tag" "size-6 text-base-content/60" }}
<label for="tag-selector" class="sr-only">Select image tag</label>
<select id="tag-selector" class="select select-sm select-bordered font-mono"
hx-get="/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"
hx-target="#tag-content"
hx-swap="outerHTML"
hx-push-url="true"
hx-include="this"
hx-indicator="#tag-swap-spinner"
name="tag">
{{ range .AllTags }}
<option value="{{ . }}"{{ if eq . $.SelectedTag.Info.Tag.Tag }} selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
<span id="tag-swap-spinner" class="htmx-indicator" aria-hidden="true">
{{ icon "loader" "size-4 animate-spin" }}
</span>
{{ if gt (len .AllTags) 1 }}
<div class="dropdown dropdown-end" id="diff-dropdown">
<label tabindex="0" class="btn btn-ghost btn-sm gap-1" title="Compare with another tag">
<button type="button" tabindex="0" class="btn btn-ghost btn-sm gap-1" title="Compare with another tag" aria-haspopup="menu" aria-expanded="false">
{{ icon "git-compare" "size-4" }} Diff
</label>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto">
</button>
<ul tabindex="0" role="menu" class="dropdown-content menu bg-base-200 rounded-box z-10 w-auto min-w-56 max-w-md p-2 shadow max-h-60 overflow-y-auto">
{{ range .AllTags }}
<li><a href="#" data-action="diff-to" data-diff-to="{{ . }}">{{ . }}</a></li>
<li role="none"><button type="button" role="menuitem" data-action="diff-to" data-diff-to="{{ . }}" class="truncate text-left">{{ . }}</button></li>
{{ end }}
</ul>
</div>
@@ -113,7 +126,7 @@
<span class="badge badge-sm badge-outline font-mono">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
{{ else }}
{{ else if gt (len .SelectedTag.Info.Platforms) 0 }}
{{ $p := index .SelectedTag.Info.Platforms 0 }}
{{ if $p.OS }}
<div id="platform-badges">
@@ -141,12 +154,21 @@
<div id="overview-edit" class="card bg-base-200 shadow-sm p-6 hidden">
<!-- Write/Preview tabs -->
<div class="border-b border-base-300 mb-4">
<nav class="flex gap-0" role="tablist">
<nav class="flex gap-0" role="tablist" aria-label="README editor mode">
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
id="tab-write"
role="tab"
aria-selected="true"
aria-controls="editor-write"
data-tab="write" data-action="switch-editor-tab">
Write
</button>
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
id="tab-preview"
role="tab"
aria-selected="false"
aria-controls="editor-preview"
tabindex="-1"
data-tab="preview" data-action="switch-editor-tab">
Preview
</button>
@@ -154,33 +176,33 @@
</div>
<!-- Write panel -->
<div id="editor-write" class="editor-panel">
<div id="editor-write" class="editor-panel" role="tabpanel" aria-labelledby="tab-write">
<!-- Toolbar -->
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="heading" title="Heading">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="heading" title="Heading" aria-label="Heading">
{{ icon "heading" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="bold" title="Bold">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="bold" title="Bold" aria-label="Bold">
{{ icon "bold" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="italic" title="Italic">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="italic" title="Italic" aria-label="Italic">
{{ icon "italic" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="link" title="Link">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="link" title="Link" aria-label="Link">
{{ icon "link" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="image" title="Image">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="image" title="Image" aria-label="Image">
{{ icon "image" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ul" title="Bulleted list">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ul" title="Bulleted list" aria-label="Bulleted list">
{{ icon "list" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ol" title="Numbered list">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ol" title="Numbered list" aria-label="Numbered list">
{{ icon "list-ordered" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="code" title="Code">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="code" title="Code" aria-label="Code">
{{ icon "code" "size-4" }}
</button>
</div>
@@ -194,8 +216,8 @@
</div>
<!-- Preview panel -->
<div id="editor-preview" class="editor-panel hidden">
<div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg">
<div id="editor-preview" class="editor-panel hidden" role="tabpanel" aria-labelledby="tab-preview">
<div id="preview-content" class="prose prose-sm max-w-none min-h-80 p-4 border border-base-300 rounded-lg">
<p class="text-base-content/60">Nothing to preview</p>
</div>
</div>
@@ -210,6 +232,7 @@
</div>
</main>
{{ if .IsOwner }}
<!-- Manifest Delete Confirmation Modal -->
<dialog id="manifest-delete-modal" class="modal" aria-modal="true" aria-labelledby="manifest-delete-title">
<div class="modal-box bg-base-200">
@@ -243,6 +266,7 @@
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{{ end }}
<!-- Attestation Details Modal -->
<dialog id="attestation-detail-modal" class="modal" aria-modal="true" aria-labelledby="attestation-detail-title">
+16 -7
View File
@@ -10,19 +10,28 @@
<main id="main-content" class="container mx-auto px-4 py-8">
{{ if .SearchQuery }}
<h1 class="text-2xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h1>
<h1 class="text-2xl font-bold mb-6 wrap-break-word line-clamp-2">Search Results for "{{ .SearchQuery }}"</h1>
{{ else }}
<h1 class="text-2xl font-bold mb-2">Search</h1>
<p class="text-base-content/60 mb-6">Enter a search term to find images.</p>
{{ end }}
<div id="search-results" hx-get="/api/search-results?q={{ .SearchQuery }}" hx-trigger="load" hx-swap="innerHTML">
<!-- Initial loading state -->
{{/* Noscript fallback: a plain GET form so users without JavaScript
can still search. The nav search box posts to /search too, but
that's above the fold of the page result; this gives a stable
on-page form at the top of results. */}}
<noscript>
<form action="/search" method="get" class="mb-6 flex gap-2">
<input type="text" name="q" value="{{ .SearchQuery }}"
class="input input-bordered flex-1"
placeholder="Search images">
<button type="submit" class="btn btn-primary">Search</button>
</form>
</noscript>
<div id="search-results" role="region" aria-live="polite" aria-busy="false">
{{ if .SearchQuery }}
<div class="flex items-center gap-2 text-base-content/60">
<span class="loading loading-spinner loading-sm"></span>
<span>Searching...</span>
</div>
{{ template "search-results" .Results }}
{{ end }}
</div>
</main>
+38 -273
View File
@@ -14,295 +14,60 @@
<!-- Mobile identity info (below lg) -->
<div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/70">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
<div>{{ with .Profile.PDSEndpoint }}<a href="{{ . }}/account" target="_blank" rel="noopener noreferrer" class="link link-primary inline-flex items-center gap-1 break-all max-w-full" title="{{ . }}">{{ . }} {{ icon "external-link" "size-3 shrink-0" }}</a>{{ end }}</div>
</div>
<!-- Mobile tab bar (below lg) -->
{{ $active := .ActiveTab }}
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6" role="tablist" aria-label="Settings sections" aria-orientation="horizontal">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user" role="tab" id="tab-mobile-user" aria-controls="tab-user" aria-selected="false" tabindex="-1">
{{ icon "user" "size-4" }} User
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing" role="tab" id="tab-mobile-billing" aria-controls="tab-billing" aria-selected="false" tabindex="-1">
{{ icon "credit-card" "size-4" }} Billing
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage" role="tab" id="tab-mobile-storage" aria-controls="tab-storage" aria-selected="false" tabindex="-1">
{{ icon "hard-drive" "size-4" }} Storage
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices" role="tab" id="tab-mobile-devices" aria-controls="tab-devices" aria-selected="false" tabindex="-1">
{{ icon "terminal" "size-4" }} Devices
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks" role="tab" id="tab-mobile-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1">
{{ icon "webhook" "size-4" }} Webhooks
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced" role="tab" id="tab-mobile-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1">
{{ icon "shield-check" "size-4" }} Advanced
</button>
{{ range .Tabs }}
<a href="/settings/{{ .Slug }}"
class="btn btn-sm {{ if eq .Slug $active }}btn-secondary{{ else }}btn-ghost{{ end }} settings-tab-mobile shrink-0"
data-tab="{{ .Slug }}"
hx-get="/settings/{{ .Slug }}"
hx-target="#tab-content"
hx-swap="innerHTML show:top"
hx-push-url="true"
role="tab"
id="tab-mobile-{{ .Slug }}"
aria-controls="tab-content"
aria-selected="{{ if eq .Slug $active }}true{{ else }}false{{ end }}"
tabindex="{{ if eq .Slug $active }}0{{ else }}-1{{ end }}">
{{ icon .Icon "size-4" }} {{ .Label }}
</a>
{{ end }}
</div>
<div class="flex gap-8">
<!-- Sidebar (lg and above) -->
<aside class="hidden lg:block w-56 shrink-0">
<ul class="menu bg-base-200 rounded-box w-full" role="tablist" aria-label="Settings sections" aria-orientation="vertical">
<li data-tab="user" role="none"><a href="#user" role="tab" id="tab-desktop-user" aria-controls="tab-user" aria-selected="false" tabindex="-1">{{ icon "user" "size-4" }} User</a></li>
<li data-tab="billing" role="none"><a href="#billing" role="tab" id="tab-desktop-billing" aria-controls="tab-billing" aria-selected="false" tabindex="-1">{{ icon "credit-card" "size-4" }} Billing</a></li>
<li data-tab="storage" role="none"><a href="#storage" role="tab" id="tab-desktop-storage" aria-controls="tab-storage" aria-selected="false" tabindex="-1">{{ icon "hard-drive" "size-4" }} Storage</a></li>
<li data-tab="devices" role="none"><a href="#devices" role="tab" id="tab-desktop-devices" aria-controls="tab-devices" aria-selected="false" tabindex="-1">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="webhooks" role="none"><a href="#webhooks" role="tab" id="tab-desktop-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1">{{ icon "webhook" "size-4" }} Webhooks</a></li>
<li data-tab="advanced" role="none"><a href="#advanced" role="tab" id="tab-desktop-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1">{{ icon "shield-check" "size-4" }} Advanced</a></li>
{{ range .Tabs }}
<li data-tab="{{ .Slug }}" role="none" {{ if eq .Slug $active }}class="menu-active"{{ end }}>
<a href="/settings/{{ .Slug }}"
hx-get="/settings/{{ .Slug }}"
hx-target="#tab-content"
hx-swap="innerHTML show:top"
hx-push-url="true"
role="tab"
id="tab-desktop-{{ .Slug }}"
aria-controls="tab-content"
aria-selected="{{ if eq .Slug $active }}true{{ else }}false{{ end }}"
tabindex="{{ if eq .Slug $active }}0{{ else }}-1{{ end }}">
{{ icon .Icon "size-4" }} {{ .Label }}
</a>
</li>
{{ end }}
</ul>
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/70">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
<div>{{ with .Profile.PDSEndpoint }}<a href="{{ . }}/account" target="_blank" rel="noopener noreferrer" class="link link-primary inline-flex items-center gap-1 break-all max-w-full" title="{{ . }}">{{ . }} {{ icon "external-link" "size-3 shrink-0" }}</a>{{ end }}</div>
</div>
</aside>
<!-- Tab content -->
<div class="flex-1 min-w-0">
<!-- USER TAB -->
<div id="tab-user" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-user" tabindex="0">
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Preferences</h2>
<p class="text-base-content/70 mt-1">Customize your experience across the site.</p>
</div>
<!-- Preferred Client Selector -->
<div class="flex items-center gap-4">
<div>
<label for="oci-client-select" class="text-sm font-medium">Preferred client</label>
<p id="oci-client-hint" class="text-xs text-base-content/70">Sets the pull command shown on repository pages. Choose <em>Image reference only</em> to copy without a command prefix.</p>
</div>
{{ $oci := .Profile.OciClient }}
<select id="oci-client-select" aria-describedby="oci-client-hint" class="select select-sm select-bordered min-w-40"
name="oci_client"
hx-post="/api/profile/oci-client"
hx-trigger="change"
hx-swap="none">
<option value="docker"{{ if or (eq $oci "") (eq $oci "docker") }} selected{{ end }}>Docker</option>
<option value="podman"{{ if eq $oci "podman" }} selected{{ end }}>Podman</option>
<option value="buildah"{{ if eq $oci "buildah" }} selected{{ end }}>Buildah</option>
<option value="nerdctl"{{ if eq $oci "nerdctl" }} selected{{ end }}>nerdctl</option>
<option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option>
<option value="none"{{ if eq $oci "none" }} selected{{ end }}>Image reference only</option>
</select>
</div>
<!-- AI Image Advisor Toggle -->
{{ if .AIAdvisorEnabled }}
<div class="divider my-2"></div>
<div class="flex items-start gap-3">
{{ if .Profile.HasAIAdvisorAccess }}
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/ai-advisor"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AIAdvisorEnabled }}checked{{ end }}>
<div>
<span class="font-medium">AI Image Advisor</span>
<p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p>
</div>
</label>
{{ else }}
<div>
<span class="font-medium text-base-content/50">AI Image Advisor</span>
<p class="text-xs text-base-content/70">Analyze your container images for optimization suggestions using AI.</p>
<p class="text-xs text-primary mt-1">
<a href="/settings#billing">Upgrade your plan</a> to enable this feature.
</p>
</div>
{{ end }}
</div>
{{ end }}
</section>
</div>
<!-- STORAGE TAB -->
<div id="tab-storage" class="settings-panel hidden space-y-4" role="tabpanel" aria-labelledby="tab-desktop-storage" tabindex="0">
<!-- Holds -->
{{ if .AllHolds }}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-4">
{{ template "hold_selector" . }}
{{ if .ActiveHold }}
{{ template "hold_card" .ActiveHold }}
{{ else }}
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
No active hold selected. Choose one above.
</div>
{{ end }}
</div>
<div>
{{ if .OtherHolds }}
{{ template "other_holds_table" .OtherHolds }}
{{ end }}
</div>
</div>
{{ else }}
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
No holds configured. Push an image to get started.
</div>
{{ end }}
<!-- Storage Preferences -->
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Storage Preferences</h2>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/auto-remove-untagged"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AutoRemoveUntagged }}checked{{ end }}>
<div>
<span class="font-medium">Automatically remove untagged manifests</span>
<p class="text-sm text-base-content/60 mt-1">
When a tag is overwritten, the old manifest and its layers are cleaned up.
Multi-arch child manifests are preserved.
</p>
</div>
</label>
</section>
</div>
<!-- BILLING TAB -->
<div id="tab-billing" class="settings-panel hidden space-y-4" role="tabpanel" aria-labelledby="tab-desktop-billing" tabindex="0">
{{ template "subscription_plans" .Subscription }}
</div>
<!-- DEVICES TAB -->
<div id="tab-devices" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-devices" tabindex="0">
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Authorized Devices</h2>
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
</div>
<!-- Setup Instructions -->
<div class="bg-base-200 rounded-lg p-4 space-y-4">
<h3 class="font-semibold">First Time Setup</h3>
<ol class="list-decimal list-inside space-y-4 text-sm">
<li>Install credential helper:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre>
</li>
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
"credHelpers": {
"{{ .RegistryURL }}": "atcr"
}
}</code></pre>
</li>
<li>Run any Docker command:
<div class="mt-2">{{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
</li>
<li>Browser will open for authorization - click Approve</li>
<li>Done! Device is automatically authorized</li>
</ol>
<div class="pt-3 border-t border-base-300 text-sm">
<strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking)
</div>
</div>
<!-- Devices List -->
<div class="space-y-3">
<h3 class="font-semibold">Your Authorized Devices</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Device Name</th>
<th>IP Address</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="devices-table"
hx-get="/api/devices"
hx-trigger="tab:devices from:body once, every 30s[isTabActive('devices')], devicesChanged from:body"
hx-swap="innerHTML">
<tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<!-- WEBHOOKS TAB -->
<div id="tab-webhooks" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-webhooks" tabindex="0">
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<div>
<h2 class="text-xl font-semibold">Webhooks</h2>
<p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p>
</div>
<div id="webhooks-content">
{{ template "webhooks_list" .WebhooksData }}
</div>
</section>
</div>
<!-- ADVANCED TAB -->
<div id="tab-advanced" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-advanced" tabindex="0">
<!-- Data Privacy Section -->
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Data Privacy</h2>
<p class="text-base-content/70">Download a copy of all data we store about you.</p>
<div>
<a href="/api/export-data" class="btn btn-secondary gap-2" download>
{{ icon "download" "size-4" }}
Export All My Data
</a>
</div>
<p class="text-sm text-base-content/60">
This includes your authorized devices, sessions, and hold memberships.
Data stored on your PDS is already under your control.
See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details.
</p>
</section>
<!-- Danger Zone Section -->
<section class="border-2 border-error rounded-lg p-6 space-y-4">
<h2 class="text-xl font-semibold text-error flex items-center gap-2">
{{ icon "alert-triangle" "size-5" }}
Danger Zone
</h2>
<div class="space-y-4">
<div>
<h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3>
<p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p>
</div>
<div class="alert bg-base-200">
{{ icon "info" "size-5 shrink-0" }}
<span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span>
</div>
<div class="space-y-2">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5">
<span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span>
</label>
<p class="text-xs text-base-content/60 ml-7">
This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS.
Other records in your account are not impacted.
</p>
</div>
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2"
data-client-short-name="{{ .ClientShortName }}"
data-profile-handle="{{ .Profile.Handle }}">
{{ icon "trash-2" "size-5" }}
Delete My {{ .ClientShortName }} Data
</button>
</div>
</section>
</div>
<!-- Active tab content -->
<div id="tab-content" class="flex-1 min-w-0 space-y-6" role="tabpanel" aria-labelledby="tab-desktop-{{ $active }}" tabindex="0">
{{ template "settings-panel" . }}
</div>
</div>
</main>
+2 -2
View File
@@ -9,8 +9,8 @@
{{ template "nav" . }}
<main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-3xl font-display font-bold tracking-tight mb-2">Terms of Service - {{ .CompanyName }} ({{ .SiteURL }})</h1>
<p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
<h1 class="text-3xl font-display font-bold tracking-tight mb-2 wrap-break-word">Terms of Service &mdash; {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}</h1>
<p class="text-base-content/60 mb-8"><em>Last updated: {{ .LastUpdated }}</em></p>
<p class="mb-8">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.</p>
+13 -7
View File
@@ -15,24 +15,24 @@
{{ if .ViewedUser.Avatar }}
<div class="avatar">
<div class="w-20 rounded-full shadow">
<img src="{{ resizeImage .ViewedUser.Avatar 160 }}" alt="{{ .ViewedUser.Handle }}" width="80" height="80" fetchpriority="high" />
<img src="{{ resizeImage .ViewedUser.Avatar 160 }}" alt="" aria-hidden="true" width="80" height="80" fetchpriority="high" />
</div>
</div>
{{ else if .HasProfile }}
<div class="avatar avatar-placeholder">
<div class="avatar avatar-placeholder" role="img" aria-label="Avatar for {{ .ViewedUser.Handle }}">
<div class="bg-neutral text-neutral-content w-20 rounded-full shadow">
<span class="text-3xl">{{ firstChar .ViewedUser.Handle }}</span>
<span aria-hidden="true" class="text-3xl">{{ firstChar .ViewedUser.Handle }}</span>
</div>
</div>
{{ else }}
<div class="avatar avatar-placeholder">
<div class="avatar avatar-placeholder" role="img" aria-label="Unknown user avatar">
<div class="bg-neutral text-neutral-content/60 w-20 rounded-full shadow">
<span class="text-3xl">?</span>
<span aria-hidden="true" class="text-3xl">?</span>
</div>
</div>
{{ end }}
<div class="flex items-center gap-2">
<h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight">{{ .ViewedUser.Handle }}</h1>
<div class="flex flex-wrap items-center justify-center gap-2 min-w-0">
<h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight break-all min-w-0">{{ .ViewedUser.Handle }}</h1>
{{ if or (eq .SupporterBadge "Captain") (eq .SupporterBadge "owner") }}
<span class="badge badge-sm supporter-badge-owner">{{ .SupporterBadge }}</span>
{{ else if .SupporterBadge }}
@@ -46,6 +46,12 @@
<div class="text-center text-base-content/60 py-12">
<p>This user hasn't set up their {{ .ClientShortName }} profile yet.</p>
</div>
{{ 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 }}
<div class="w-full">
{{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No images yet.") }}
+7 -3
View File
@@ -1,9 +1,13 @@
{{ define "alert" }}
{{ if eq .Type "success" }}
<div class="success">{{ icon "check" "size-5" }} {{ .Message }}</div>
<div class="alert alert-success wrap-break-word" role="status" aria-live="polite">{{ icon "check-circle" "size-5 shrink-0" }} <span>{{ .Message }}</span></div>
{{ else if eq .Type "error" }}
<div class="error">{{ icon "alert-circle" "size-5" }} {{ .Message }}</div>
<div class="alert alert-error wrap-break-word" role="alert" aria-live="assertive">{{ icon "alert-circle" "size-5 shrink-0" }} <span>{{ .Message }}</span></div>
{{ else if eq .Type "warning" }}
<div class="alert alert-warning wrap-break-word" role="alert" aria-live="polite">{{ icon "alert-triangle" "size-5 shrink-0" }} <span>{{ .Message }}</span></div>
{{ else if eq .Type "info" }}
<div class="alert alert-info wrap-break-word" role="status" aria-live="polite">{{ icon "info" "size-5 shrink-0" }} <span>{{ .Message }}</span></div>
{{ else }}
<div class="{{ .Class }}">{{ .Message }}</div>
<div class="alert wrap-break-word" role="status" aria-live="polite"><span>{{ .Message }}</span></div>
{{ end }}
{{ end }}
@@ -1,20 +1,29 @@
{{ define "attestation-details" }}
{{ if .Error }}
<p class="text-base-content/60">{{ .Error }}</p>
<p class="text-base-content/60 wrap-break-word">{{ .Error }}</p>
{{ else }}
<div class="space-y-4">
<p class="font-semibold text-sm">{{ len .Attestations }} attestation{{ if gt (len .Attestations) 1 }}s{{ end }} attached</p>
<p class="font-semibold text-sm">{{ len .Attestations }} {{ pluralize (len .Attestations) "attestation" "attestations" }} attached</p>
{{ range .Attestations }}
<div class="bg-base-200 rounded-lg p-4 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
{{/* "Unknown" / "Binary" etc. predicates shouldn't read as success-green. */}}
{{ if or (eq .PredicateType "Unknown") (eq .PredicateType "Binary") }}
<span class="badge badge-md badge-ghost">{{ .PredicateType }}</span>
{{ else }}
<span class="badge badge-md badge-soft badge-success">{{ .PredicateType }}</span>
{{ end }}
<code class="font-mono text-xs text-base-content/60 truncate max-w-48" title="{{ .Digest }}">{{ .Digest }}</code>
</div>
{{ if .NeedsLogin }}
{{ if $.LoginURL }}
<p class="text-sm text-base-content/70"><a href="{{ $.LoginURL }}" class="link link-primary">Log in</a> to view attestation content</p>
{{ else }}
<p class="text-sm text-base-content/70"><a href="/auth/oauth/login" class="link link-primary">Log in</a> to view attestation content</p>
{{ end }}
{{ else if .FetchError }}
<p class="text-sm text-base-content/70">{{ .FetchError }}</p>
<p class="text-sm text-base-content/70 wrap-break-word">{{ .FetchError }}</p>
{{ else if .RawJSON }}
<details>
<summary class="cursor-pointer text-sm text-base-content/70 hover:text-base-content">View content</summary>
@@ -23,9 +32,11 @@
</div>
</details>
{{ else if .Size }}
<p class="text-sm text-base-content/70">Binary content ({{ .Size }} bytes) — cannot display inline</p>
<p class="text-sm text-base-content/70">Binary content ({{ humanizeBytes .Size }}) — cannot display inline</p>
{{ end }}
</div>
{{ else }}
<p class="text-base-content/60">No attestations attached to this manifest.</p>
{{ end }}
</div>
{{ end }}
@@ -1,8 +1,8 @@
{{ define "devices-table" }}
{{ range .Devices }}
<tr id="device-{{ .ID }}">
<td>{{ .Name }}</td>
<td class="font-mono text-sm">{{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }}</td>
<td><span class="truncate inline-block max-w-xs align-middle" title="{{ .Name }}">{{ .Name }}</span></td>
<td class="font-mono text-sm"><span class="truncate inline-block max-w-48 align-middle" title="{{ .IPAddress }}">{{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }}</span></td>
<td>{{ formatDate .CreatedAt }}</td>
<td>{{ if isZeroTime .LastUsed }}Never{{ else }}{{ formatDate .LastUsed }}{{ end }}</td>
<td>
@@ -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" }}
</button>
</td>
@@ -7,18 +7,19 @@
{{ if .LayerDiff }}
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Layer differences</caption>
<thead>
<tr class="text-xs">
<th class="w-6"></th>
<th class="w-8">#</th>
<th>Command</th>
<th class="text-right w-24">Size</th>
<th scope="col" class="w-6"><span class="sr-only">Change</span></th>
<th scope="col" class="w-8">#</th>
<th scope="col">Command</th>
<th scope="col" class="text-right w-24">Size</th>
</tr>
</thead>
<tbody>
{{ range .LayerDiff }}
<tr class="{{ if eq .Status "added" }}bg-success/10{{ else if eq .Status "removed" }}bg-error/10{{ else if eq .Status "rebuilt" }}bg-warning/10{{ else }}opacity-60{{ end }}">
<td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{ if eq .Status "added" }}+{{ else if eq .Status "removed" }}-{{ else if eq .Status "rebuilt" }}~{{ end }}</td>
<td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{/* Glyph + sr-only label: color is redundant information. */}}{{ if eq .Status "added" }}<span aria-hidden="true">+</span><span class="sr-only">Added</span>{{ else if eq .Status "removed" }}<span aria-hidden="true">-</span><span class="sr-only">Removed</span>{{ else if eq .Status "rebuilt" }}<span aria-hidden="true">~</span><span class="sr-only">Rebuilt</span>{{ else }}<span class="sr-only">Unchanged</span>{{ end }}</td>
<td class="font-mono text-xs">{{ .Layer.Index }}</td>
<td>
{{ if .Layer.Command }}
@@ -48,7 +49,18 @@
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
{{ if not .HasVulnData }}
<p class="text-base-content/60">Vulnerability scan data not available for both manifests</p>
{{/* 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") }}
<div class="alert alert-warning" role="status">
{{ icon "wifi-off" "size-4 shrink-0" }}
<span>We couldn't reach the hold to fetch scan data. Try again in a moment.</span>
</div>
{{ else if or (eq .FromScanStatus "no-data") (eq .ToScanStatus "no-data") }}
<p class="text-base-content/60">Neither manifest has been scanned yet. Vulnerability comparison will appear after both scans complete.</p>
{{ else }}
<p class="text-base-content/60">Vulnerability scan data isn't available for both manifests.</p>
{{ end }}
{{ else }}
<!-- Fixed Vulns -->
@@ -62,26 +74,27 @@
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Vulnerabilities fixed in the newer manifest</caption>
<thead>
<tr class="text-xs">
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Was</th>
<th scope="col">CVE</th>
<th scope="col">Severity</th>
<th scope="col">Package</th>
<th scope="col">Was</th>
</tr>
</thead>
<tbody>
{{ range .FixedVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a>
{{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span>
</td>
<td class="text-xs">{{ .Package }}</td>
<td class="text-xs font-mono">{{ .Version }}</td>
<td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td>
</tr>
{{ end }}
</tbody>
@@ -102,27 +115,28 @@
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Vulnerabilities new to the newer manifest</caption>
<thead>
<tr class="text-xs">
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Version</th>
<th>Fix</th>
<th scope="col">CVE</th>
<th scope="col">Severity</th>
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Fix</th>
</tr>
</thead>
<tbody>
{{ range .NewVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a>
{{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span>
</td>
<td class="text-xs">{{ .Package }}</td>
<td class="text-xs font-mono">{{ .Version }}</td>
<td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td>
<td class="text-xs font-mono">{{ .FixedIn }}</td>
</tr>
{{ end }}
@@ -143,27 +157,28 @@
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Vulnerabilities present in both manifests</caption>
<thead>
<tr class="text-xs">
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Version</th>
<th>Fix</th>
<th scope="col">CVE</th>
<th scope="col">Severity</th>
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Fix</th>
</tr>
</thead>
<tbody>
{{ range .UnchangedVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a>
{{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span>
</td>
<td class="text-xs">{{ .Package }}</td>
<td class="text-xs font-mono">{{ .Version }}</td>
<td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td>
<td class="text-xs font-mono">{{ .FixedIn }}</td>
</tr>
{{ end }}
@@ -13,11 +13,12 @@
{{ if .Layers }}
<div class="overflow-x-auto">
<table class="table table-xs w-full layers-table">
<caption class="sr-only">Image layers</caption>
<thead>
<tr class="text-xs">
<th class="w-8">#</th>
<th>Command</th>
<th class="text-right w-24">Size</th>
<th scope="col" class="w-8">#</th>
<th scope="col">Command</th>
<th scope="col" class="text-right">Size</th>
</tr>
</thead>
<tbody>
@@ -43,8 +44,8 @@
<!-- Vulnerabilities + SBOM (Right) -->
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<div role="tablist" class="tabs tabs-bordered">
<input type="radio" name="scan-tabs" role="tab" class="tab" aria-label="Vulnerabilities" checked="checked" />
<div role="tabpanel" class="tab-content pt-4">
<input type="radio" id="scan-tab-vulns" name="scan-tabs" role="tab" class="tab" aria-label="Vulnerabilities" aria-controls="scan-panel-vulns" checked="checked" />
<div id="scan-panel-vulns" role="tabpanel" aria-labelledby="scan-tab-vulns" class="tab-content pt-4">
{{ if .VulnData }}
{{ template "vuln-details" .VulnData }}
{{ else }}
@@ -52,8 +53,8 @@
{{ end }}
</div>
<input type="radio" name="scan-tabs" role="tab" class="tab" aria-label="SBOM" />
<div role="tabpanel" class="tab-content pt-4">
<input type="radio" id="scan-tab-sbom" name="scan-tabs" role="tab" class="tab" aria-label="SBOM" aria-controls="scan-panel-sbom" />
<div id="scan-panel-sbom" role="tabpanel" aria-labelledby="scan-tab-sbom" class="tab-content pt-4">
{{ if .SbomData }}
{{ template "sbom-details" .SbomData }}
{{ else }}
@@ -1,10 +1,24 @@
{{ define "health-badge" }}
{{ if .Pending }}
<span class="badge badge-sm badge-info"
<span class="badge badge-sm badge-info" role="status" aria-live="polite"
hx-get="/api/manifest-health?endpoint={{ .RetryURL }}"
hx-trigger="load delay:3s"
hx-swap="outerHTML">{{ icon "refresh-ccw" "size-3" }} Checking...</span>
hx-swap="outerHTML">{{ icon "refresh-ccw" "size-3 animate-spin" }} Checking</span>
{{ else if not .Reachable }}
<span class="badge badge-sm badge-warning">{{ icon "triangle-alert" "size-3" }} Offline</span>
{{/* 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" }}
<span class="badge badge-sm badge-warning" role="status" title="DNS lookup failed — the hostname can't be resolved">{{ icon "triangle-alert" "size-3" }} DNS failed</span>
{{ else if eq .Reason "tls" }}
<span class="badge badge-sm badge-warning" role="status" title="TLS handshake failed — certificate may be expired or invalid">{{ icon "triangle-alert" "size-3" }} TLS error</span>
{{ else if eq .Reason "refused" }}
<span class="badge badge-sm badge-warning" role="status" title="Connection refused — nothing is listening on the endpoint">{{ icon "triangle-alert" "size-3" }} Refused</span>
{{ else if eq .Reason "timeout" }}
<span class="badge badge-sm badge-warning" role="status" title="Request timed out — the hold is slow or unreachable">{{ icon "triangle-alert" "size-3" }} Timeout</span>
{{ else if eq .Reason "http" }}
<span class="badge badge-sm badge-warning" role="status" title="The hold responded with an error status">{{ icon "triangle-alert" "size-3" }} HTTP error</span>
{{ else }}
<span class="badge badge-sm badge-warning" role="status" title="We couldn't reach the hold">{{ icon "triangle-alert" "size-3" }} Offline</span>
{{ end }}
{{ end }}
{{ end }}
@@ -4,12 +4,12 @@
<div class="p-4 flex flex-wrap items-center gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold text-lg truncate">{{ .DisplayName }}</h3>
<span class="badge badge-sm badge-soft badge-primary">Active</span>
<h3 class="font-semibold text-lg truncate">{{ or .DisplayName .DID }}</h3>
{{ if eq .Membership "owner" }}<span class="badge badge-sm badge-primary">Owner</span>
{{ else }}<span class="badge badge-sm badge-secondary">Crew</span>{{ end }}
{{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Online</span>
{{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Offline</span>
{{ else }}<span class="badge badge-sm badge-ghost gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Unknown</span>
{{ end }}
</div>
<code class="text-xs text-base-content/70 break-all">{{ .DID }}</code>
@@ -21,7 +21,8 @@
<div id="storage-stats-active"
hx-get="/api/storage?hold_did={{ .DID | urlquery }}"
hx-trigger="load, tab:storage from:body once"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-on::after-request="if(!event.detail.successful) this.innerHTML='<p class=\'text-sm text-base-content/70\'>Storage unavailable.</p>'">
<p class="flex items-center gap-2 text-sm text-base-content/70">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p>
</div>
</div>
@@ -8,27 +8,25 @@
<option value="" selected>-- Select a hold --</option>
{{ end }}
{{ if .MemberHolds }}
<optgroup label="Your Holds">
{{ range .AllHolds }}
{{ if ne .Membership "eligible" }}
{{ range .MemberHolds }}
<option value="{{ .DID }}" {{ if .IsActive }}selected{{ end }}>
{{ .DisplayName }}{{ if eq .Membership "owner" }} (Owner){{ else }} (Crew){{ end }}{{ if .Region }} &middot; {{ .Region }}{{ end }}
</option>
{{ end }}
{{ end }}
</optgroup>
{{ end }}
{{ range .AllHolds }}{{ if eq .Membership "eligible" }}
{{ if .EligibleHolds }}
<optgroup label="Available Holds">
{{ range $.AllHolds }}
{{ if eq .Membership "eligible" }}
{{ range .EligibleHolds }}
<option value="{{ .DID }}">
{{ .DisplayName }}{{ if .Region }} &middot; {{ .Region }}{{ end }} (Join)
</option>
{{ end }}
{{ end }}
</optgroup>
{{ break }}{{ end }}{{ end }}
{{ end }}
</select>
<noscript><button type="submit" class="btn btn-sm btn-primary">Switch</button></noscript>
</form>
@@ -2,7 +2,7 @@
{{ if eq .Error "upgrade_required" }}
<div class="alert alert-info text-sm">
{{ icon "sparkles" "size-4" }}
<span>AI Image Advisor is a paid feature. <a href="/settings#billing" class="link link-primary font-medium">Upgrade your plan</a> to unlock image analysis.</span>
<span>AI Image Advisor is a paid feature. <a href="/settings/billing" class="link link-primary font-medium">Upgrade your plan</a> to unlock image analysis.</span>
</div>
{{ else if .Error }}
<div class="alert alert-warning text-sm">
@@ -18,19 +18,20 @@
</h3>
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">AI optimization suggestions for this image</caption>
<thead>
<tr>
<th>Action</th>
<th>Category</th>
<th>Impact</th>
<th>Effort</th>
<th class="w-1/2">Detail</th>
<th scope="col">Action</th>
<th scope="col">Category</th>
<th scope="col">Impact</th>
<th scope="col">Effort</th>
<th scope="col" class="w-1/2">Detail</th>
</tr>
</thead>
<tbody>
{{ range .Suggestions }}
<tr>
<td class="font-medium text-sm">{{ .Action }}</td>
<td class="font-medium text-sm wrap-break-word">{{ .Action }}</td>
<td>
<span class="badge badge-sm badge-ghost whitespace-nowrap">{{ .Category }}</span>
</td>
@@ -49,10 +50,10 @@
{{ else if eq .Effort "medium" }}
<span class="badge badge-sm badge-warning">medium</span>
{{ else }}
<span class="badge badge-sm badge-ghost">high</span>
<span class="badge badge-sm badge-outline">high</span>
{{ end }}
</td>
<td class="text-xs max-w-xs">
<td class="text-sm max-w-md wrap-break-word">
{{ .Detail }}
{{ if gt .CVEsFixed 0 }}
<span class="badge badge-xs badge-outline badge-error ml-1">{{ .CVEsFixed }} CVEs</span>
@@ -66,7 +67,7 @@
</tbody>
</table>
</div>
<p class="text-xs text-base-content/70">Generated by Claude Haiku. Suggestions are advisory only.</p>
<p class="text-xs text-base-content/70">Generated by {{ or .Model "Claude" }}. Suggestions are advisory only.</p>
</div>
</div>
{{ else }}
@@ -7,14 +7,20 @@
<span>Show empty layers</span>
</label>
</div>
{{ 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 }}
<div class="overflow-x-auto">
<table class="table table-xs w-full layers-table">
<caption class="sr-only">Image layer history</caption>
<thead>
<tr class="text-xs">
<th class="w-8">#</th>
<th>Command</th>
<th class="text-right w-24">Size</th>
<th scope="col" class="w-8">#</th>
<th scope="col">Command</th>
<th scope="col" class="text-right">Size</th>
</tr>
</thead>
<tbody>
@@ -24,6 +30,8 @@
<td>
{{ if .Command }}
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
{{ else if not .EmptyLayer }}
<span class="text-xs text-base-content/40 italic">— no command recorded</span>
{{ end }}
</td>
<td class="text-right text-sm whitespace-nowrap" data-bytes="{{ .Size }}">{{ humanizeBytes .Size }}</td>
@@ -5,19 +5,21 @@
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<caption class="sr-only">Other holds you are a member of</caption>
<thead>
<tr>
<th>Hold</th>
<th>Role</th>
<th class="text-center">Status</th>
<th class="text-right">Storage</th>
<th scope="col">Hold</th>
<th scope="col">Role</th>
<th scope="col" class="text-center">Status</th>
<th scope="col" class="text-right">Storage</th>
</tr>
</thead>
<tbody>
{{ range . }}
<tr>
<td>
<span class="font-medium">{{ .DisplayName }}</span>
<span class="font-medium block max-w-55 truncate" title="{{ .DID }}">{{ or .DisplayName .DID }}</span>
<code class="block font-mono text-xs text-base-content/50 truncate max-w-55">{{ .DID }}</code>
</td>
<td>
{{ if eq .Membership "owner" }}<span class="badge badge-xs badge-primary">Owner</span>
@@ -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='&mdash;'"
class="text-sm font-mono">
...
</span>
@@ -1,5 +1,5 @@
{{ define "repo-tag-section" }}
<div id="tag-content" data-owner="{{ .Owner.Handle }}" data-repo="{{ .Repository.Name }}"{{ if .SelectedTag }} data-digest="{{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"{{ end }}>
<div id="tag-content" data-owner="{{ .Owner.Handle }}" data-repo="{{ .Repository.Name }}"{{ if .SelectedTag }} data-digest="{{ if and .SelectedTag.Info.IsMultiArch .SelectedTag.Info.Platforms }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"{{ end }}>
{{ if .SelectedTag }}
<!-- Pull Command with Client Switcher -->
{{ 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 @@
<div class="mt-2 flex flex-wrap gap-2 items-center text-xs text-base-content/70">
<span>Hosted on:</span>
{{ range .NonDefaultHolds }}
<span class="badge badge-outline badge-sm" title="{{ . }}">{{ displayHoldDID . }}</span>
<span class="badge badge-outline badge-sm max-w-32 truncate" title="{{ . }}">{{ displayHoldDID . }}</span>
{{ end }}
<span class="text-base-content/70">(different from your default hold)</span>
<span class="text-base-content/70">(not your default hold)</span>
</div>
{{ end }}
@@ -36,9 +36,11 @@
<div class="card bg-base-200 border border-base-300 p-4">
<div class="text-xs font-semibold uppercase tracking-wider text-base-content/70 mb-2">Vulnerabilities</div>
<div id="vuln-summary-card">
{{ if .SelectedTag.Info.Platforms }}
{{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }}
<span id="scan-badge-{{ trimPrefix "sha256:" $firstPlatform.Digest }}"></span>
<span id="vuln-loading-text" class="text-sm text-base-content/70">Loading...</span>
{{ end }}
</div>
</div>
@@ -77,7 +79,7 @@
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="overview"
role="tab"
aria-selected="false"
aria-selected="true"
aria-controls="tab-overview"
id="overview-tab-btn"
data-action="switch-repo-tab">
@@ -130,18 +132,18 @@
{{ if and .AIAdvisorEnabled .User .IsOwner .SelectedTag }}
<div id="ai-advisor-section">
<button id="ai-advisor-btn" class="btn btn-sm btn-outline gap-1"
hx-get="/api/image-advisor/{{ .Owner.Handle }}/{{ .Repository.Name }}?digest={{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"
hx-get="/api/image-advisor/{{ .Owner.Handle }}/{{ .Repository.Name }}?digest={{ if and .SelectedTag.Info.IsMultiArch .SelectedTag.Info.Platforms }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"
hx-target="#ai-advisor-results"
hx-swap="innerHTML"
hx-indicator="#ai-advisor-spinner"
hx-on::after-request="this.disabled=true">
hx-on::after-request="if(event.detail.successful){this.disabled=true}">
{{ icon "sparkles" "size-4" }}
Analyze Image
</button>
<span id="ai-advisor-spinner" class="htmx-indicator">
{{ icon "loader" "size-4 animate-spin" }}
</span>
<div id="ai-advisor-results" class="mt-4"></div>
<div id="ai-advisor-results" class="mt-4" role="status" aria-live="polite"></div>
</div>
{{ end }}
@@ -157,6 +159,20 @@
<div id="overview-rendered" class="prose prose-sm max-w-none">
{{ 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. */}}
<div class="text-center py-12">
{{ icon "alert-triangle" "size-12 text-warning mx-auto" }}
<p class="text-base-content/70 mt-4">We couldn't load the README</p>
<p class="text-base-content/60 text-sm mt-1">The configured README source didn't respond. It may be rate-limited or private.</p>
{{ if .IsOwner }}
<button class="btn btn-outline btn-sm mt-4" data-action="toggle-editor" data-show="true">
{{ icon "pencil" "size-4" }} Edit README
</button>
{{ end }}
</div>
{{ else }}
{{ if .IsOwner }}
<div class="text-center py-12">
@@ -19,7 +19,7 @@
</button>
{{ end }}
{{ if and .ViewerDefaultHold .Entry.HoldEndpoint (ne .Entry.HoldEndpoint .ViewerDefaultHold) }}
<span class="badge badge-xs badge-soft badge-warning" title="{{ .Entry.HoldEndpoint }}">{{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }}</span>
<span class="badge badge-xs badge-soft badge-warning max-w-32 truncate" title="{{ .Entry.HoldEndpoint }}">{{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }}</span>
{{ end }}
</div>
<div class="flex items-center gap-2 shrink-0">
@@ -29,7 +29,7 @@
<button class="btn btn-ghost btn-sm text-error"
hx-ext="json-enc"
hx-delete="/api/tags"
hx-vals='{"repo": "{{ .RepoName }}", "tag": "{{ .Entry.Label }}"}'
hx-vals='{{ dict "repo" .RepoName "tag" .Entry.Label | toJSON }}'
hx-confirm="Delete tag {{ .Entry.Label }}?"
hx-target="closest .artifact-entry"
hx-swap="outerHTML"
@@ -101,6 +101,8 @@
</td>
<td class="text-sm text-base-content whitespace-nowrap">{{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}</td>
</tr>
{{ else }}
<tr><td colspan="4" class="text-base-content/60 text-center py-4">No platform details available for this manifest.</td></tr>
{{ end }}
</tbody>
</table>
@@ -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()">
<span id="load-more-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span>
Load More
</button>
</div>
@@ -137,7 +141,7 @@
<!-- Filter/Sort Controls -->
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium whitespace-nowrap">Sort by</span>
<label for="tag-sort" class="text-sm font-medium whitespace-nowrap">Sort by</label>
<select id="tag-sort" class="select select-sm select-bordered min-w-28" data-action="sort-tags">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
@@ -146,6 +150,7 @@
</select>
</div>
<div class="flex-1 max-w-xs">
<label for="tag-filter" class="sr-only">Filter artifacts</label>
<input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." data-action="filter-tags">
</div>
{{ if $.IsOwner }}
@@ -1,11 +1,14 @@
{{ define "sbom-details" }}
{{ if .Error }}
<p>{{ .Error }}</p>
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
<div class="alert alert-error" role="alert">
{{ icon "alert-circle" "size-5 shrink-0" }}
<span>{{ .Error }}</span>
</div>
{{ if .ScannedAt }}<p class="text-xs text-base-content/60 mt-2">Scanned: {{ .ScannedAt }}</p>{{ end }}
{{ else }}
<div class="space-y-4" data-csv-section data-csv-filename="sbom.csv">
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm">{{ .Total }} packages</span>
<span class="font-semibold text-sm">{{ .Total }} {{ pluralize .Total "package" "packages" }}</span>
{{ if .Packages }}
<details class="dropdown dropdown-end ml-auto">
<summary class="btn btn-ghost btn-xs gap-1 list-none" aria-label="Export SBOM">
@@ -23,22 +26,29 @@
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
{{ if .Packages }}
<div class="overflow-y-auto max-h-[32rem]">
<table class="table table-xs table-pin-rows w-full">
<div class="overflow-y-auto max-h-128">
<table class="table table-xs table-pin-rows w-full table-fixed">
<caption class="sr-only">SBOM package list</caption>
<colgroup>
<col class="w-5/12">
<col class="w-3/12">
<col class="w-3/12">
<col class="w-1/12">
</colgroup>
<thead>
<tr>
<th>Package</th>
<th>Version</th>
<th>License</th>
<th>Type</th>
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">License</th>
<th scope="col">Type</th>
</tr>
</thead>
<tbody>
{{ range .Packages }}
<tr>
<td class="text-xs">{{ .Name }}</td>
<td class="font-mono text-xs">{{ .Version }}</td>
<td class="text-xs">
<td class="text-xs truncate" title="{{ .Name }}">{{ .Name }}</td>
<td class="font-mono text-xs truncate" title="{{ .Version }}">{{ .Version }}</td>
<td class="text-xs truncate" title="{{ .License }}">
{{ if eq .License "-" }}
<span class="text-base-content/40">-</span>
{{ else }}
@@ -1,9 +1,16 @@
{{ define "sbom-section" }}
<div class="space-y-4 min-w-0 pt-6">
{{ if .SbomData }}
<div class="space-y-4 min-w-0 pt-6" role="region" aria-live="polite">
{{ if eq .SbomReason "ok" }}
{{ template "sbom-details" .SbomData }}
{{ else if eq .SbomReason "hold-unreachable" }}
<div class="alert alert-warning" role="status">
{{ icon "wifi-off" "size-4 shrink-0" }}
<span>We couldn't reach the hold to load the SBOM.</span>
</div>
{{ else if eq .SbomReason "fetch-failed" }}
<p class="text-base-content/70">SBOM data couldn't be loaded. Try refreshing in a minute.</p>
{{ else }}
<p class="text-base-content">No SBOM data available</p>
<p class="text-base-content/70">No SBOM available yet. The scanner generates an SBOM alongside each scan.</p>
{{ end }}
</div>
{{ end }}
@@ -1,11 +1,34 @@
{{/* Search results partial - renders repository cards in a grid */}}
{{ template "card-grid" (dict
{{ define "search-results" }}
{{/* Search results partial — rendered server-side on initial page load and
swapped in by htmx for Load More pagination. Receives searchResults struct:
.Repositories, .SearchQuery, .HasMore, .NextOffset, .HasError */}}
{{ if .HasError }}
{{ template "state-error" (dict
"Title" "Search is temporarily unavailable"
"Subtext" "The search service had trouble running your query. Try again in a moment."
"RetryURL" (printf "/api/search-results?q=%s" (urlquery .SearchQuery))
"RetryTarget" "#search-results"
) }}
{{ else }}
{{ template "card-grid" (dict
"Repositories" .Repositories
"Columns" 4
"EmptyIcon" "search-x"
"EmptyMessage" "No repositories found matching your search."
"EmptySubtext" "Try a different search term or browse the homepage."
"LoadMoreURL" (printf "/api/search-results?q=%s&offset=%d" (urlquery .SearchQuery) .NextOffset)
"TargetID" "search-results-grid"
"HasMore" .HasMore
) }}
{{ end }}
{{ end }}
{{/* card-grid-append-search — Load More fragment for /api/search-results. */}}
{{ define "card-grid-append-search" }}
{{ template "card-grid-append" (dict
"Repositories" .Repositories
"Columns" 4
"EmptyIcon" "search-x"
"EmptyMessage" "No repositories found matching your search."
"EmptySubtext" "Try a different search term or browse the homepage."
"LoadMoreURL" (printf "/api/search-results?q=%s&offset=%d" .SearchQuery .NextOffset)
"TargetID" "search-results"
"LoadMoreURL" (printf "/api/search-results?q=%s&offset=%d" (urlquery .SearchQuery) .NextOffset)
"TargetID" "search-results-grid"
"HasMore" .HasMore
) }}
{{ end }}
@@ -0,0 +1,56 @@
{{ define "settings-panel-advanced" }}
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Data Privacy</h2>
<p class="text-base-content/70">Download a copy of all data we store about you.</p>
<div>
<a href="/api/export-data" class="btn btn-secondary gap-2" download>
{{ icon "download" "size-4" }}
Export All My Data
</a>
</div>
<p class="text-sm text-base-content/60">
This includes your authorized devices, sessions, and hold memberships.
Data stored on your PDS is already under your control.
See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details.
</p>
</section>
<section class="border-2 border-error rounded-lg p-6 space-y-4" role="region" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-xl font-semibold text-error flex items-center gap-2">
{{ icon "alert-triangle" "size-5" }}
Danger Zone
</h2>
<div class="space-y-4">
<div>
<h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3>
<p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p>
</div>
<div class="alert bg-base-200">
{{ icon "info" "size-5 shrink-0" }}
<span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span>
</div>
<div class="space-y-2">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5">
<span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span>
</label>
<p class="text-xs text-base-content/60 ml-7">
This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS.
Other records in your account are not impacted.
</p>
</div>
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2"
data-client-short-name="{{ .ClientShortName }}"
data-profile-handle="{{ .Profile.Handle }}">
{{ icon "trash-2" "size-5" }}
Delete My {{ .ClientShortName }} Data
</button>
</div>
</section>
{{ end }}
@@ -0,0 +1,10 @@
{{ define "settings-panel-billing" }}
{{ if .Subscription.HideBilling }}
<section class="card bg-base-200 shadow-sm p-6 space-y-3">
<h2 class="text-xl font-semibold">Billing</h2>
<p class="text-base-content/70">Billing is not enabled on this deployment.</p>
</section>
{{ else }}
{{ template "subscription_plans" .Subscription }}
{{ end }}
{{ end }}
@@ -0,0 +1,56 @@
{{ define "settings-panel-devices" }}
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Authorized Devices</h2>
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
</div>
<div class="bg-base-200 rounded-lg p-4 space-y-4">
<h3 class="font-semibold">First Time Setup</h3>
<ol class="list-decimal list-inside space-y-4 text-sm">
<li>Install credential helper:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre>
</li>
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
"credHelpers": {
"{{ .RegistryURL }}": "atcr"
}
}</code></pre>
</li>
<li>Run any Docker command:
<div class="mt-2">{{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
</li>
<li>Browser will open for authorization - click Approve</li>
<li>Done! Device is automatically authorized</li>
</ol>
<div class="pt-3 border-t border-base-300 text-sm">
<strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking)
</div>
</div>
<div class="space-y-3">
<h3 class="font-semibold">Your Authorized Devices</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Device Name</th>
<th>IP Address</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="devices-table"
hx-get="/api/devices"
hx-trigger="load, every 30s, devicesChanged from:body"
hx-swap="innerHTML">
<tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
{{ end }}
@@ -0,0 +1,9 @@
{{ define "settings-panel" }}
{{ if eq .ActiveTab "user" }}{{ template "settings-panel-user" . }}
{{ else if eq .ActiveTab "storage" }}{{ template "settings-panel-storage" . }}
{{ else if eq .ActiveTab "billing" }}{{ template "settings-panel-billing" . }}
{{ else if eq .ActiveTab "devices" }}{{ template "settings-panel-devices" . }}
{{ else if eq .ActiveTab "webhooks" }}{{ template "settings-panel-webhooks" . }}
{{ else if eq .ActiveTab "advanced" }}{{ template "settings-panel-advanced" . }}
{{ end }}
{{ end }}
@@ -0,0 +1,43 @@
{{ define "settings-panel-storage" }}
{{ if .AllHolds }}
<div class="grid grid-cols-1 {{ if .OtherHolds }}lg:grid-cols-2{{ end }} gap-4">
<div class="space-y-4">
{{ template "hold_selector" . }}
{{ if .ActiveHold }}
{{ template "hold_card" .ActiveHold }}
{{ else }}
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
No active hold selected. Choose one above.
</div>
{{ end }}
</div>
{{ if .OtherHolds }}
<div>
{{ template "other_holds_table" .OtherHolds }}
</div>
{{ end }}
</div>
{{ else }}
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
No holds configured. Push an image to get started.
</div>
{{ end }}
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Storage Preferences</h2>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/auto-remove-untagged"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AutoRemoveUntagged }}checked{{ end }}>
<div>
<span class="font-medium">Automatically remove untagged manifests</span>
<p class="text-sm text-base-content/60 mt-1">
When a tag is overwritten, the old manifest and its layers are cleaned up.
Multi-arch child manifests are preserved.
</p>
</div>
</label>
</section>
{{ end }}
@@ -0,0 +1,55 @@
{{ define "settings-panel-user" }}
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Preferences</h2>
<p class="text-base-content/70 mt-1">Customize your experience across the site.</p>
</div>
<div class="flex items-center gap-4">
<div>
<label for="oci-client-select" class="text-sm font-medium">Preferred client</label>
<p id="oci-client-hint" class="text-xs text-base-content/70">Sets the pull command shown on repository pages. Choose <em>Image reference only</em> to copy without a command prefix.</p>
</div>
{{ $oci := .Profile.OciClient }}
<select id="oci-client-select" aria-describedby="oci-client-hint" class="select select-sm select-bordered min-w-40"
name="oci_client"
hx-post="/api/profile/oci-client"
hx-trigger="change"
hx-swap="none">
<option value="docker"{{ if or (eq $oci "") (eq $oci "docker") }} selected{{ end }}>Docker</option>
<option value="podman"{{ if eq $oci "podman" }} selected{{ end }}>Podman</option>
<option value="buildah"{{ if eq $oci "buildah" }} selected{{ end }}>Buildah</option>
<option value="nerdctl"{{ if eq $oci "nerdctl" }} selected{{ end }}>nerdctl</option>
<option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option>
<option value="none"{{ if eq $oci "none" }} selected{{ end }}>Image reference only</option>
</select>
</div>
{{ if .AIAdvisorEnabled }}
<div class="divider my-2"></div>
<div class="flex items-start gap-3">
{{ if .Profile.HasAIAdvisorAccess }}
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/ai-advisor"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AIAdvisorEnabled }}checked{{ end }}>
<div>
<span class="font-medium">AI Image Advisor</span>
<p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p>
</div>
</label>
{{ else }}
<div>
<span class="font-medium text-base-content/50">AI Image Advisor</span>
<p class="text-xs text-base-content/70">Analyze your container images for optimization suggestions using AI.</p>
<p class="text-xs text-primary mt-1">
<a href="/settings/billing">Upgrade your plan</a> to enable this feature.
</p>
</div>
{{ end }}
</div>
{{ end }}
</section>
{{ end }}
@@ -0,0 +1,11 @@
{{ define "settings-panel-webhooks" }}
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<div>
<h2 class="text-xl font-semibold">Webhooks</h2>
<p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p>
</div>
<div id="webhooks-content">
{{ template "webhooks_list" .WebhooksData }}
</div>
</section>
{{ end }}
+60
View File
@@ -0,0 +1,60 @@
{{/*
Shared empty / error / pending state blocks.
state-empty — genuine empty state (no data yet, nothing to show)
state-error — request failed; includes optional Retry button
state-pending — in-flight indicator (spinner + optional label)
Fields (all optional unless noted):
.Title string — main heading text
.Subtext string — secondary supporting copy
.Icon string — icon sprite id (defaults per block)
.ActionURL string — primary CTA href (state-empty only)
.ActionLabel string — primary CTA label (state-empty only)
.RetryURL string — htmx GET URL for retry (state-error only)
.RetryTarget string — htmx target selector (default: "closest [data-state-container]")
*/}}
{{ define "state-empty" }}
<div class="py-12 text-center" role="status">
<div class="text-base-content/60 mb-4">
{{ icon (or .Icon "inbox") "size-12 mx-auto mb-4" }}
</div>
<p class="text-lg">{{ or .Title "Nothing here yet." }}</p>
{{ if .Subtext }}<p class="text-base-content/70 text-sm mt-2">{{ .Subtext }}</p>{{ end }}
{{ if and .ActionURL .ActionLabel }}
<a href="{{ .ActionURL }}" class="btn btn-primary btn-sm mt-4">{{ .ActionLabel }}</a>
{{ end }}
</div>
{{ end }}
{{ define "state-error" }}
<div class="py-12 text-center" role="alert" aria-live="assertive">
<div class="text-error mb-4">
{{ icon (or .Icon "alert-triangle") "size-12 mx-auto mb-4" }}
</div>
<p class="text-lg">{{ or .Title "Something went wrong" }}</p>
{{ if .Subtext }}<p class="text-base-content/70 text-sm mt-2">{{ .Subtext }}</p>{{ end }}
{{ if .RetryURL }}
{{ if .RetryTarget }}
{{/* Partial retry: swap just the container back in via htmx. */}}
<button class="btn btn-outline btn-sm mt-4"
hx-get="{{ .RetryURL }}"
hx-target="{{ .RetryTarget }}"
hx-swap="outerHTML">
Try again
</button>
{{ else }}
{{/* Full-page retry: regular anchor, falls back gracefully without htmx. */}}
<a href="{{ .RetryURL }}" class="btn btn-outline btn-sm mt-4">Try again</a>
{{ end }}
{{ end }}
</div>
{{ end }}
{{ define "state-pending" }}
<div class="py-12 text-center" role="status" aria-live="polite">
<span class="loading loading-spinner loading-md"></span>
{{ if .Title }}<p class="text-base-content/70 text-sm mt-2">{{ .Title }}</p>{{ end }}
</div>
{{ end }}
@@ -3,7 +3,17 @@
{{ if .Tier }}
<div class="flex justify-between items-center">
<span class="text-base-content/60">Tier:</span>
<span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span>
{{/* Whitelist known tier names so an unrecognized tier falls back to
a safe neutral badge instead of producing a broken class. */}}
{{ if eq .Tier "deckhand" }}
<span class="badge badge-xs badge-deckhand font-semibold">{{ .Tier }}</span>
{{ else if eq .Tier "bosun" }}
<span class="badge badge-xs badge-bosun font-semibold">{{ .Tier }}</span>
{{ else if eq .Tier "quartermaster" }}
<span class="badge badge-xs badge-quartermaster font-semibold">{{ .Tier }}</span>
{{ else }}
<span class="badge badge-xs badge-ghost font-semibold">{{ .Tier }}</span>
{{ end }}
</div>
{{ end }}
<div class="flex justify-between items-center">
@@ -18,9 +28,16 @@
</div>
{{ if .HasLimit }}
<div class="flex items-center gap-2 py-2">
<progress class="progress {{ if ge .UsagePercent 95 }}progress-error{{ else if ge .UsagePercent 80 }}progress-warning{{ else }}progress-success{{ end }} w-full" value="{{ .UsagePercent }}" max="100"></progress>
<progress class="progress {{ if ge .UsagePercent 95 }}progress-error{{ else if ge .UsagePercent 80 }}progress-warning{{ else }}progress-success{{ end }} w-full" value="{{ .UsagePercent }}" max="100" aria-label="Storage used: {{ .UsagePercent }} percent"></progress>
<span class="text-sm text-base-content/60 whitespace-nowrap">{{ .UsagePercent }}% used</span>
</div>
{{/* Color alone on the progress bar doesn't meet AAA — repeat the warning
as an alert block once usage crosses the threshold. */}}
{{ if ge .UsagePercent 95 }}
{{ template "alert" (dict "Type" "error" "Message" "You're nearly at your storage limit. Pushes may fail once you exceed it.") }}
{{ else if ge .UsagePercent 80 }}
{{ template "alert" (dict "Type" "warning" "Message" "You're using most of your storage quota. Consider cleaning up untagged images.") }}
{{ end }}
{{ end }}
<div class="flex justify-between items-center">
<span class="text-base-content/60">Unique Blobs:</span>
@@ -1,6 +1,13 @@
{{ define "subscription_plans" }}
{{ if not .HideBilling }}
{{ if .Tiers }}
{{ if not .Tiers }}
{{/* Billing is enabled (HideBilling=false) but no tiers were returned —
covers the config-loaded-but-empty case. Explicit copy beats silent render. */}}
<section class="card bg-base-200 shadow-sm p-6">
<h3 class="text-xl font-semibold">Available Plans</h3>
<p class="text-sm text-base-content/70 mt-2">Plan information is temporarily unavailable. Check back in a minute.</p>
</section>
{{ else }}
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h3 class="text-xl font-semibold">Available Plans</h3>
<div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
@@ -10,8 +10,8 @@
{{ if gt .Summary.VulnFixedBySev.Critical 0 }}<span class="font-semibold text-error">{{ .Summary.VulnFixedBySev.Critical }} Critical</span>{{ end }}
{{ if gt .Summary.VulnFixedBySev.High 0 }}{{ if gt .Summary.VulnFixedBySev.Critical 0 }}, {{ end }}<span class="font-semibold text-warning">{{ .Summary.VulnFixedBySev.High }} High</span>{{ end }}
{{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ if or (gt .Summary.VulnFixedBySev.Critical 0) (gt .Summary.VulnFixedBySev.High 0) }}, {{ end }}<span class="font-semibold">{{ .Summary.VulnFixedBySev.Medium }} Medium</span>{{ end }}
{{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) }}<span class="font-semibold">{{ .Summary.VulnFixedCount }} Low</span>{{ end }}
vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}
{{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) (gt .Summary.VulnFixedBySev.Low 0) }}<span class="font-semibold">{{ .Summary.VulnFixedBySev.Low }} Low</span>{{ end }}
{{ pluralize .Summary.VulnFixedCount "vuln" "vulns" }}
{{ else }}
is available
{{ end }}
@@ -22,10 +22,10 @@
is available
{{ end }}
{{ if ne .Summary.LayerCountFrom .Summary.LayerCountTo }}
· {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} layer{{ if ne (sub .Summary.LayerCountTo .Summary.LayerCountFrom) 1 }}s{{ end }}
· {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} {{ pluralize (sub .Summary.LayerCountTo .Summary.LayerCountFrom) "layer" "layers" }}
{{ end }}
{{ if ne .Summary.SizeDelta 0 }}
({{ humanizeByteDelta .Summary.SizeDelta }})
· {{ if gt .Summary.SizeDelta 0 }}{{ humanizeByteDelta .Summary.SizeDelta }} larger{{ else }}{{ humanizeByteDelta .Summary.SizeDelta }}<span class="sr-only"> smaller</span>{{ end }}
{{ end }}
</div>
<a href="{{ .DiffURL }}" class="btn btn-sm btn-info btn-outline shrink-0">View diff</a>
@@ -1,8 +1,13 @@
{{ define "vuln-badge" }}
{{ if .Error }}
{{/* Silently hide on error / no scan record — scan badges are non-critical */}}
{{/* Hold unreachable. Warning color distinguishes from "not scanned" (gray). */}}
<span class="badge badge-sm badge-warning" title="Hold is unreachable — try again in a moment">
{{ icon "wifi-off" "size-3" }} Hold offline
</span>
{{ else if .NotScanned }}
<span class="badge badge-sm badge-ghost" title="No scan recorded yet">Not scanned</span>
{{ else if .ScanFailed }}
{{/* Scan failed (no SBOM blob) — don't show misleading "Clean" badge */}}
<span class="badge badge-sm badge-warning" title="Scanner ran but produced no SBOM">{{ icon "alert-triangle" "size-3" }} Scan failed</span>
{{ else if eq .Total 0 }}
<span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span>
{{ else }}
@@ -1,6 +1,6 @@
{{ define "vuln-details" }}
{{ if .Error }}
{{ if .Summary.Total }}
{{ if gt .Summary.Total 0 }}
<!-- Summary available but no detailed report -->
<div class="space-y-4">
<span class="vuln-strip" role="group" aria-label="Vulnerability summary by severity">
@@ -13,7 +13,10 @@
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
</div>
{{ else }}
<p>{{ .Error }}</p>
<div class="alert alert-warning" role="alert">
{{ icon "alert-triangle" "size-5 shrink-0" }}
<span>{{ .Error }}</span>
</div>
{{ end }}
{{ else }}
<div class="space-y-4" data-csv-section data-csv-filename="vulnerabilities.csv">
@@ -44,15 +47,16 @@
{{ if .Matches }}
<!-- CVE table -->
<div class="overflow-x-auto overflow-y-auto max-h-[32rem]">
<table class="table table-xs table-pin-rows w-full min-w-[40rem]">
<div class="overflow-x-auto overflow-y-auto max-h-128">
<table class="table table-xs table-pin-rows w-full min-w-160">
<caption class="sr-only">Detected vulnerabilities</caption>
<thead>
<tr>
<th>CVE</th>
<th></th>
<th>Package</th>
<th>Version</th>
<th>Fix</th>
<th scope="col">CVE</th>
<th scope="col"><span class="sr-only">Severity</span></th>
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Fix</th>
</tr>
</thead>
<tbody>
@@ -67,15 +71,15 @@
</td>
<td>
{{ if eq .Severity "Critical" }}
<span class="badge badge-xs badge-error" title="Critical">C</span>
<span class="badge badge-xs badge-error" title="Critical" aria-label="Critical"><span aria-hidden="true">C</span></span>
{{ else if eq .Severity "High" }}
<span class="badge badge-xs badge-warning" title="High">H</span>
<span class="badge badge-xs badge-warning" title="High" aria-label="High"><span aria-hidden="true">H</span></span>
{{ else if eq .Severity "Medium" }}
<span class="badge badge-xs badge-soft badge-warning" title="Medium">M</span>
<span class="badge badge-xs badge-soft badge-warning" title="Medium" aria-label="Medium"><span aria-hidden="true">M</span></span>
{{ else if eq .Severity "Low" }}
<span class="badge badge-xs badge-info" title="Low">L</span>
<span class="badge badge-xs badge-info" title="Low" aria-label="Low"><span aria-hidden="true">L</span></span>
{{ else }}
<span class="badge badge-xs badge-ghost" title="{{ .Severity }}">?</span>
<span class="badge badge-xs badge-ghost" title="{{ .Severity }}" aria-label="{{ or .Severity "Unknown severity" }}"><span aria-hidden="true">?</span></span>
{{ end }}
</td>
<td class="text-xs">
@@ -1,7 +1,20 @@
{{ define "vulns-section" }}
<div class="space-y-4 min-w-0 pt-6">
{{ if .VulnData }}
<div class="space-y-4 min-w-0 pt-6" role="region" aria-live="polite">
{{ if eq .VulnReason "ok" }}
{{ template "vuln-details" .VulnData }}
{{ else if eq .VulnReason "hold-unreachable" }}
<div class="alert alert-warning" role="status">
{{ icon "wifi-off" "size-4 shrink-0" }}
<div>
<p class="font-medium">We couldn't reach the hold</p>
<p class="text-sm">Scan data is stored on the hold. It may be offline or unreachable right now.</p>
</div>
</div>
{{ else if eq .VulnReason "fetch-failed" }}
<div class="py-8 text-sm text-base-content/70 max-w-prose">
<p class="font-medium text-base-content">Scan data couldn't be loaded</p>
<p class="mt-1">The hold is reachable but didn't return scan results for this manifest. Try refreshing the page in a minute.</p>
</div>
{{ else }}
<div class="py-8 text-sm text-base-content/70 max-w-prose">
<p class="font-medium text-base-content">No vulnerability scan available yet</p>
@@ -4,6 +4,7 @@
<form hx-post="/api/webhooks"
hx-target="#{{ .ContainerID }}"
hx-swap="innerHTML"
hx-disabled-elt="find button[type='submit']"
class="space-y-4 bg-base-200 rounded-lg p-4">
<h3 class="font-semibold">Add Webhook</h3>
@@ -30,7 +31,7 @@
<div class="space-y-2 mt-1">
{{ range .TriggerInfo }}
<label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}">
<input type="checkbox" name="trigger_{{ if eq .Name "push" }}push{{ else if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
<input type="checkbox" name="{{ .FormName }}"
class="checkbox checkbox-sm mt-0.5"
{{ if .DefaultChecked }}checked{{ end }}
{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}>

Some files were not shown because too many files have changed in this diff Show More