# ATCR AppView UI - Implementation Guide This document provides step-by-step implementation details for building the ATCR web UI using **html/template + HTMX**. ## Tech Stack (Finalized) - **Backend:** Go (existing AppView) - **Templates:** `html/template` (standard library) - **Interactivity:** HTMX (~14KB) + Alpine.js (~15KB, optional) - **Database:** SQLite (firehose cache) - **Styling:** Simple CSS or Tailwind (TBD) - **Authentication:** OAuth (existing implementation) ## Project Structure ``` cmd/appview/ ├── main.go # Add AppView routes here pkg/appview/ ├── appview.go # Main AppView setup, embed directives ├── handlers/ # HTTP handlers │ ├── home.go # Front page (firehose) │ ├── settings.go # Settings page │ ├── images.go # Personal images page │ └── auth.go # Login/logout handlers ├── db/ # Database layer │ ├── schema.go # SQLite schema │ ├── queries.go # DB queries │ └── models.go # Data models ├── firehose/ # Firehose worker │ ├── worker.go # Background worker │ └── jetstream.go # Jetstream client ├── middleware/ # HTTP middleware │ ├── auth.go # Session auth │ └── csrf.go # CSRF protection ├── session/ # Session management │ └── session.go # Session store ├── templates/ # HTML templates (embedded) │ ├── layouts/ │ │ └── base.html # Base layout │ ├── components/ │ │ ├── nav.html # Navigation bar │ │ └── modal.html # Modal dialogs │ ├── pages/ │ │ ├── home.html # Front page │ │ ├── settings.html # Settings page │ │ └── images.html # Personal images │ └── partials/ # HTMX partials │ ├── push-list.html # Push list partial │ └── tag-row.html # Tag row partial └── static/ # Static assets (embedded) ├── css/ │ └── style.css └── js/ └── app.js # Minimal JS (clipboard, etc.) ``` ## Step 1: Embed Setup ### Main AppView Package **pkg/appview/appview.go:** ```go package appview import ( "embed" "html/template" "io/fs" "net/http" ) //go:embed templates/*.html templates/**/*.html var templatesFS embed.FS //go:embed static/* var staticFS embed.FS // Templates returns parsed templates func Templates() (*template.Template, error) { return template.ParseFS(templatesFS, "templates/**/*.html") } // StaticHandler returns HTTP handler for static files func StaticHandler() http.Handler { sub, _ := fs.Sub(staticFS, "static") return http.FileServer(http.FS(sub)) } ``` ## Step 2: Database Setup ### Create Schema **pkg/appview/db/schema.go:** ```go package db import ( "database/sql" _ "github.com/mattn/go-sqlite3" ) const schema = ` CREATE TABLE IF NOT EXISTS users ( did TEXT PRIMARY KEY, handle TEXT NOT NULL, pds_endpoint TEXT NOT NULL, last_seen TIMESTAMP NOT NULL, UNIQUE(handle) ); CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); CREATE TABLE IF NOT EXISTS manifests ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, repository TEXT NOT NULL, digest TEXT NOT NULL, hold_endpoint TEXT NOT NULL, schema_version INTEGER NOT NULL, media_type TEXT NOT NULL, config_digest TEXT, config_size INTEGER, raw_manifest TEXT NOT NULL, created_at TIMESTAMP NOT NULL, UNIQUE(did, repository, digest), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); CREATE TABLE IF NOT EXISTS layers ( manifest_id INTEGER NOT NULL, digest TEXT NOT NULL, size INTEGER NOT NULL, media_type TEXT NOT NULL, layer_index INTEGER NOT NULL, PRIMARY KEY(manifest_id, layer_index), FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, repository TEXT NOT NULL, tag TEXT NOT NULL, digest TEXT NOT NULL, created_at TIMESTAMP NOT NULL, UNIQUE(did, repository, tag), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository); CREATE TABLE IF NOT EXISTS firehose_cursor ( id INTEGER PRIMARY KEY CHECK (id = 1), cursor INTEGER NOT NULL, updated_at TIMESTAMP NOT NULL ); ` func InitDB(path string) (*sql.DB, error) { db, err := sql.Open("sqlite3", path) if err != nil { return nil, err } if _, err := db.Exec(schema); err != nil { return nil, err } return db, nil } ``` ### Data Models **pkg/appview/db/models.go:** ```go package db import "time" type User struct { DID string Handle string PDSEndpoint string LastSeen time.Time } type Manifest struct { ID int64 DID string Repository string Digest string HoldEndpoint string SchemaVersion int MediaType string ConfigDigest string ConfigSize int64 RawManifest string // JSON CreatedAt time.Time } type Tag struct { ID int64 DID string Repository string Tag string Digest string CreatedAt time.Time } type Push struct { Handle string Repository string Tag string Digest string HoldEndpoint string CreatedAt time.Time } type Repository struct { Name string TagCount int ManifestCount int LastPush time.Time Tags []Tag Manifests []Manifest } ``` ### Query Functions **pkg/appview/db/queries.go:** ```go package db import ( "database/sql" "time" ) // GetRecentPushes fetches recent pushes with pagination func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { query := ` SELECT u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest ` if userFilter != "" { query += " WHERE u.handle = ? OR u.did = ?" } query += " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" var rows *sql.Rows var err error if userFilter != "" { rows, err = db.Query(query, userFilter, userFilter, limit, offset) } else { rows, err = db.Query(query, limit, offset) } if err != nil { return nil, 0, err } defer rows.Close() var pushes []Push for rows.Next() { var p Push if err := rows.Scan(&p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { return nil, 0, err } pushes = append(pushes, p) } // Get total count countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did" if userFilter != "" { countQuery += " WHERE u.handle = ? OR u.did = ?" } var total int if userFilter != "" { db.QueryRow(countQuery, userFilter, userFilter).Scan(&total) } else { db.QueryRow(countQuery).Scan(&total) } return pushes, total, nil } // GetUserRepositories fetches all repositories for a user func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { // Get repository summary rows, err := db.Query(` SELECT repository, COUNT(DISTINCT tag) as tag_count, COUNT(DISTINCT digest) as manifest_count, MAX(created_at) as last_push FROM ( SELECT repository, tag, digest, created_at FROM tags WHERE did = ? UNION SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ? ) GROUP BY repository ORDER BY last_push DESC `, did, did) if err != nil { return nil, err } defer rows.Close() var repos []Repository for rows.Next() { var r Repository if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &r.LastPush); err != nil { return nil, err } // Get tags for this repo tagRows, err := db.Query(` SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ? ORDER BY created_at DESC `, did, r.Name) if err != nil { return nil, err } for tagRows.Next() { var t Tag if err := tagRows.Scan(&t.Tag, &t.Digest, &t.CreatedAt); err != nil { tagRows.Close() return nil, err } r.Tags = append(r.Tags, t) } tagRows.Close() // Get manifests for this repo manifestRows, err := db.Query(` SELECT id, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, raw_manifest, created_at FROM manifests WHERE did = ? AND repository = ? ORDER BY created_at DESC `, did, r.Name) if err != nil { return nil, err } for manifestRows.Next() { var m Manifest if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt); err != nil { manifestRows.Close() return nil, err } r.Manifests = append(r.Manifests, m) } manifestRows.Close() repos = append(repos, r) } return repos, nil } ``` ## Step 2: Templates Layout ### Base Layout **pkg/appview/templates/layouts/base.html:** ```html {{ block "title" . }}ATCR{{ end }} {{ block "head" . }}{{ end }} {{ template "nav" . }}
{{ block "content" . }}{{ end }}
{{ block "scripts" . }}{{ end }} ``` ### Navigation Component **pkg/appview/templates/components/nav.html:** ```html {{ define "nav" }} {{ end }} ``` ## Step 3: Front Page (Homepage) **pkg/appview/templates/pages/home.html:** ```html {{ define "title" }}ATCR - Federated Container Registry{{ end }} {{ define "content" }}

Recent Pushes

Loading recent pushes...
{{ end }} ``` **pkg/appview/templates/partials/push-list.html:** ```html {{ range .Pushes }}
{{ .Handle }} / {{ .Repository }} : {{ .Tag }}
{{ printf "%.12s" .Digest }}... {{ .HoldEndpoint }}
docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}
{{ end }} {{ if .HasMore }} {{ end }} ``` **pkg/appview/handlers/home.go:** ```go package handlers import ( "html/template" "net/http" "strconv" "atcr.io/pkg/appview/db" ) type HomeHandler struct { DB *sql.DB Templates *template.Template } func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Check if this is an HTMX request for the partial if r.Header.Get("HX-Request") == "true" { h.servePushList(w, r) return } // Serve full page data := struct { User *db.User }{ User: getUserFromContext(r), } h.Templates.ExecuteTemplate(w, "home.html", data) } func (h *HomeHandler) servePushList(w http.ResponseWriter, r *http.Request) { limit := 50 offset := 0 if o := r.URL.Query().Get("offset"); o != "" { offset, _ = strconv.Atoi(o) } userFilter := r.URL.Query().Get("user") pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } data := struct { Pushes []db.Push HasMore bool NextOffset int }{ Pushes: pushes, HasMore: offset+limit < total, NextOffset: offset + limit, } h.Templates.ExecuteTemplate(w, "push-list.html", data) } ``` ## Step 4: Settings Page **pkg/appview/templates/pages/settings.html:** ```html {{ define "title" }}Settings - ATCR{{ end }} {{ define "content" }}

Settings

Identity

{{ .Profile.Handle }}
{{ .Profile.DID }}
{{ .Profile.PDSEndpoint }}

Default Hold

Current: {{ .Profile.DefaultHold }}

OAuth Session

{{ .Profile.Handle }}
Re-authenticate
{{ end }} {{ define "scripts" }} {{ end }} ``` **pkg/appview/handlers/settings.go:** ```go package handlers import ( "database/sql" "encoding/json" "html/template" "net/http" "atcr.io/pkg/atproto" ) type SettingsHandler struct { Templates *template.Template ATProtoClient *atproto.Client } func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r) if user == nil { http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/settings", http.StatusFound) return } // Fetch user profile from PDS profile, err := h.ATProtoClient.GetProfile(user.DID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Fetch user's holds holds, err := h.ATProtoClient.ListHolds(user.DID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } data := struct { Profile *atproto.SailorProfileRecord Holds []atproto.HoldRecord SessionExpiry time.Time }{ Profile: profile, Holds: holds, SessionExpiry: getSessionExpiry(r), } h.Templates.ExecuteTemplate(w, "settings.html", data) } func (h *SettingsHandler) UpdateDefaultHold(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } holdEndpoint := r.FormValue("hold_endpoint") if holdEndpoint == "" { holdEndpoint = r.FormValue("custom_hold") } // Update profile in PDS err := h.ATProtoClient.UpdateProfile(user.DID, map[string]any{ "defaultHold": holdEndpoint, }) if err != nil { w.Write([]byte(`
Failed to update: ` + err.Error() + `
`)) return } w.Write([]byte(`
✓ Default hold updated successfully!
`)) } ``` ## Step 5: Personal Images Page **pkg/appview/templates/pages/images.html:** ```html {{ define "title" }}Your Images - ATCR{{ end }} {{ define "content" }}

Your Images

{{ if .Repositories }} {{ range .Repositories }}

{{ .Name }}

{{ .TagCount }} tags {{ .ManifestCount }} manifests
{{ end }} {{ else }}

No images yet. Push your first image:

docker push atcr.io/{{ .User.Handle }}/myapp:latest
{{ end }}
{{ end }} {{ define "scripts" }} {{ end }} ``` **pkg/appview/handlers/images.go:** ```go package handlers import ( "database/sql" "html/template" "net/http" "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" ) type ImagesHandler struct { DB *sql.DB Templates *template.Template ATProtoClient *atproto.Client } func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r) if user == nil { http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound) return } // Fetch repositories from PDS (user's own data) repos, err := h.ATProtoClient.ListRepositories(user.DID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } data := struct { User *db.User Repositories []db.Repository }{ User: user, Repositories: repos, } h.Templates.ExecuteTemplate(w, "images.html", data) } func (h *ImagesHandler) DeleteTag(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Extract repo and tag from URL vars := mux.Vars(r) repo := vars["repository"] tag := vars["tag"] // Delete tag record from PDS err := h.ATProtoClient.DeleteTag(user.DID, repo, tag) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Return empty response (HTMX will swap out the element) w.WriteHeader(http.StatusOK) } func (h *ImagesHandler) DeleteManifest(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) repo := vars["repository"] digest := vars["digest"] // Check if manifest is tagged tagged, err := h.ATProtoClient.IsManifestTagged(user.DID, repo, digest) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if tagged { http.Error(w, "Cannot delete tagged manifest", http.StatusBadRequest) return } // Delete manifest from PDS err = h.ATProtoClient.DeleteManifest(user.DID, repo, digest) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } ``` ## Step 6: Modals & Partials **pkg/appview/templates/components/modal.html:** ```html {{ define "manifest-modal" }} {{ end }} ``` **pkg/appview/templates/partials/edit-tag-modal.html:** ```html ``` ## Step 7: Authentication & Session **pkg/appview/session/session.go:** ```go package session import ( "crypto/rand" "encoding/base64" "net/http" "sync" "time" ) type Session struct { ID string DID string Handle string ExpiresAt time.Time } type Store struct { mu sync.RWMutex sessions map[string]*Session } func NewStore() *Store { return &Store{ sessions: make(map[string]*Session), } } func (s *Store) Create(did, handle string, duration time.Duration) (*Session, error) { s.mu.Lock() defer s.mu.Unlock() // Generate random session ID b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return nil, err } sess := &Session{ ID: base64.URLEncoding.EncodeToString(b), DID: did, Handle: handle, ExpiresAt: time.Now().Add(duration), } s.sessions[sess.ID] = sess return sess, nil } func (s *Store) Get(id string) (*Session, bool) { s.mu.RLock() defer s.mu.RUnlock() sess, ok := s.sessions[id] if !ok || time.Now().After(sess.ExpiresAt) { return nil, false } return sess, true } func (s *Store) Delete(id string) { s.mu.Lock() defer s.mu.Unlock() delete(s.sessions, id) } func (s *Store) Cleanup() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() for id, sess := range s.sessions { if now.After(sess.ExpiresAt) { delete(s.sessions, id) } } } // SetCookie sets the session cookie func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) { http.SetCookie(w, &http.Cookie{ Name: "atcr_session", Value: sessionID, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } // GetSessionID gets session ID from cookie func GetSessionID(r *http.Request) (string, bool) { cookie, err := r.Cookie("atcr_session") if err != nil { return "", false } return cookie.Value, true } ``` **pkg/appview/middleware/auth.go:** ```go package middleware import ( "context" "net/http" "atcr.io/pkg/appview/session" "atcr.io/pkg/appview/db" ) type contextKey string const userKey contextKey = "user" func RequireAuth(store *session.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionID, ok := session.GetSessionID(r) if !ok { http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) return } sess, ok := store.Get(sessionID) if !ok { http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) return } user := &db.User{ DID: sess.DID, Handle: sess.Handle, } ctx := context.WithValue(r.Context(), userKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func GetUser(r *http.Request) *db.User { user, ok := r.Context().Value(userKey).(*db.User) if !ok { return nil } return user } ``` ## Step 8: Main Integration **cmd/appview/main.go (additions):** ```go package main import ( "log" "net/http" "time" "github.com/gorilla/mux" "atcr.io/pkg/appview" "atcr.io/pkg/appview/handlers" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/session" "atcr.io/pkg/appview/middleware" ) func main() { // Initialize database database, err := db.InitDB("/var/lib/atcr/ui.db") if err != nil { log.Fatal(err) } // Initialize session store sessionStore := session.NewStore() // Start cleanup goroutine go func() { for { time.Sleep(5 * time.Minute) sessionStore.Cleanup() } }() // Load embedded templates tmpl, err := appview.Templates() if err != nil { log.Fatal(err) } // Setup router r := mux.NewRouter() // Static files (embedded) r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", appview.StaticHandler())) // UI routes (public) r.Handle("/ui/", &handlers.HomeHandler{ DB: database, Templates: tmpl, }) // UI routes (authenticated) authRouter := r.PathPrefix("/ui").Subrouter() authRouter.Use(middleware.RequireAuth(sessionStore)) authRouter.Handle("/images", &handlers.ImagesHandler{ DB: database, Templates: tmpl, }) authRouter.Handle("/settings", &handlers.SettingsHandler{ Templates: tmpl, }) // API routes authRouter.HandleFunc("/api/images/{repository}/tags/{tag}", handlers.DeleteTag).Methods("DELETE") authRouter.HandleFunc("/api/images/{repository}/manifests/{digest}", handlers.DeleteManifest).Methods("DELETE") // ... rest of your existing routes log.Println("Server starting on :5000") http.ListenAndServe(":5000", r) } ``` ## Step 9: Styling (Basic CSS) **pkg/appview/static/css/style.css:** ```css :root { --primary: #0066cc; --bg: #ffffff; --fg: #1a1a1a; --border: #e0e0e0; --code-bg: #f5f5f5; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; } .container { max-width: 1200px; margin: 0 auto; padding: 20px; } /* Navigation */ .navbar { background: var(--fg); color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; } .nav-brand a { color: white; text-decoration: none; font-size: 1.5rem; font-weight: bold; } .nav-links { display: flex; gap: 1rem; align-items: center; } .nav-links a { color: white; text-decoration: none; } /* Push Cards */ .push-card { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; background: white; } .push-header { font-size: 1.1rem; margin-bottom: 0.5rem; } .push-user { color: var(--primary); text-decoration: none; } .push-command { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; padding: 0.5rem; background: var(--code-bg); border-radius: 4px; } .pull-command { flex: 1; font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; } .copy-btn { padding: 0.25rem 0.5rem; background: var(--primary); color: white; border: none; border-radius: 4px; cursor: pointer; } /* Repository Cards */ .repository-card { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 1rem; background: white; } .repo-header { padding: 1rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: #f9f9f9; border-radius: 8px 8px 0 0; } .repo-header:hover { background: #f0f0f0; } .repo-details { padding: 1rem; } .tag-row, .manifest-row { display: flex; gap: 1rem; align-items: center; padding: 0.5rem; border-bottom: 1px solid var(--border); } .tag-row:last-child, .manifest-row:last-child { border-bottom: none; } /* Modal */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; } .modal-content { background: white; padding: 2rem; border-radius: 8px; max-width: 800px; max-height: 80vh; overflow-y: auto; position: relative; } .modal-close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; font-size: 1.5rem; cursor: pointer; } .manifest-json { background: var(--code-bg); padding: 1rem; border-radius: 4px; overflow-x: auto; font-family: 'Monaco', 'Courier New', monospace; font-size: 0.85rem; } /* Buttons */ button, .btn { padding: 0.5rem 1rem; background: var(--primary); color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; } button:hover, .btn:hover { opacity: 0.9; } .delete-btn { background: #dc3545; } /* Loading state */ .loading { text-align: center; padding: 2rem; color: #666; } /* Forms */ .form-group { margin-bottom: 1rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; } .form-group input, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem; } ``` ## Step 10: Helper Functions **pkg/appview/static/js/app.js:** ```javascript // Copy to clipboard function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { // Show success feedback const btn = event.target; const originalText = btn.textContent; btn.textContent = '✓ Copied!'; setTimeout(() => { btn.textContent = originalText; }, 2000); }); } // Time ago helper (for client-side rendering) function timeAgo(date) { const seconds = Math.floor((new Date() - new Date(date)) / 1000); const intervals = { year: 31536000, month: 2592000, week: 604800, day: 86400, hour: 3600, minute: 60, second: 1 }; for (const [name, secondsInInterval] of Object.entries(intervals)) { const interval = Math.floor(seconds / secondsInInterval); if (interval >= 1) { return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`; } } return 'just now'; } // Update timestamps on page load document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('time[datetime]').forEach(el => { const date = el.getAttribute('datetime'); el.textContent = timeAgo(date); }); }); ``` **Template helper functions (in Go):** ```go // Add to your template loading funcMap := template.FuncMap{ "timeAgo": func(t time.Time) string { duration := time.Since(t) if duration < time.Minute { return "just now" } else if duration < time.Hour { mins := int(duration.Minutes()) if mins == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", mins) } else if duration < 24*time.Hour { hours := int(duration.Hours()) if hours == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", hours) } else { days := int(duration.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } }, "humanizeBytes": func(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) }, } tmpl := template.New("").Funcs(funcMap) tmpl = template.Must(tmpl.ParseGlob("web/templates/**/*.html")) ``` ## Implementation Checklist ### Phase 1: Foundation - [ ] Set up project structure - [ ] Initialize SQLite database with schema - [ ] Create data models and query functions - [ ] Write database tests ### Phase 2: Templates - [ ] Create base layout template - [ ] Create navigation component - [ ] Create home page template - [ ] Create settings page template - [ ] Create images page template - [ ] Create modal templates ### Phase 3: Handlers - [ ] Implement home handler (firehose display) - [ ] Implement settings handler (profile + holds) - [ ] Implement images handler (repository list) - [ ] Implement API endpoints (delete tag, delete manifest) - [ ] Add HTMX partial responses ### Phase 4: Authentication - [ ] Implement session store - [ ] Create auth middleware - [ ] Wire up OAuth login (reuse existing) - [ ] Add logout functionality - [ ] Test auth flow ### Phase 5: Firehose Worker - [ ] Implement Jetstream client - [ ] Create firehose worker - [ ] Add event handlers (manifest, tag) - [ ] Test with real firehose - [ ] Add cursor persistence ### Phase 6: Polish - [ ] Add CSS styling - [ ] Implement copy-to-clipboard - [ ] Add loading states - [ ] Error handling and user feedback - [ ] Responsive design - [ ] CSRF protection ### Phase 7: Testing - [ ] Unit tests for handlers - [ ] Database query tests - [ ] Integration tests (full flow) - [ ] Manual testing with real data ## Performance Optimizations ### HTMX Optimizations 1. **Prefetching:** Add `hx-trigger="mouseenter"` to links for hover prefetch 2. **Caching:** Use `hx-cache="true"` for cacheable content 3. **Optimistic updates:** Remove elements immediately, rollback on error 4. **Debouncing:** Add `delay:500ms` to search inputs ### Database Optimizations 1. **Indexes:** Already defined in schema (did, repo, created_at, digest) 2. **Connection pooling:** Use `db.SetMaxOpenConns(25)` 3. **Prepared statements:** Cache frequently used queries 4. **Batch inserts:** For firehose events, batch into transactions ### Template Optimizations 1. **Pre-parse:** Parse templates once at startup, not per request 2. **Caching:** Cache rendered partials for static content 3. **Minification:** Minify HTML/CSS/JS in production ## Security Checklist - [ ] Session cookies: Secure, HttpOnly, SameSite=Lax - [ ] CSRF tokens for mutations (POST/DELETE) - [ ] Input validation (sanitize search, filters) - [ ] Rate limiting on API endpoints - [ ] SQL injection protection (parameterized queries) - [ ] Authorization checks (user owns resource) - [ ] XSS protection (escape template output) ## Deployment ### Development ```bash # Run migrations go run cmd/appview/main.go migrate # Start server go run cmd/appview/main.go serve ``` ### Production ```bash # Build binary go build -o bin/atcr-appview ./cmd/appview # Run with config ./bin/atcr-appview serve config/production.yml ``` ### Environment Variables ```bash UI_ENABLED=true UI_DATABASE_PATH=/var/lib/atcr/ui.db UI_FIREHOSE_ENDPOINT=wss://jetstream.atproto.tools/subscribe UI_SESSION_DURATION=24h ``` ## Next Steps After V1 1. **Add search:** Implement full-text search on SQLite 2. **Public profiles:** `/ui/@alice` shows public view 3. **Manifest diff:** Compare manifest versions 4. **Export data:** Download all your images as JSON 5. **Webhook notifications:** Alert on new pushes 6. **CLI integration:** `atcr ui open` to launch browser --- ## Key Benefits of This Approach ### Single Binary Deployment - All templates and static files embedded with `//go:embed` - No need to ship separate `web/` directory - Single `atcr-appview` binary contains everything - Easy deployment: just copy one file ### Package Structure - `pkg/appview` makes sense semantically (it's the AppView, not just UI) - Contains both backend (db, firehose) and frontend (templates, handlers) - Clear separation from core OCI registry logic - Easy to test and develop independently ### Embedded Assets ```go // pkg/appview/appview.go //go:embed templates/*.html templates/**/*.html var templatesFS embed.FS //go:embed static/* var staticFS embed.FS ``` **Build:** ```bash go build -o bin/atcr-appview ./cmd/appview ``` **Deploy:** ```bash scp bin/atcr-appview server:/usr/local/bin/ # Done! No webpack, no node_modules, no separate assets folder ``` ### Development Workflow 1. Edit templates in `pkg/appview/templates/` 2. Edit CSS/JS in `pkg/appview/static/` 3. Run `go build` - assets auto-embedded 4. No build tools, no npm, just Go --- This guide provides a complete implementation path for ATCR AppView UI using html/template + HTMX with embedded assets. Start with Phase 1 (embed setup + database) and work your way through each phase sequentially.