Files
at-container-registry/docs/APPVIEW-UI-IMPLEMENTATION.md

47 KiB

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:

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:

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:

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:

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:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ block "title" . }}ATCR{{ end }}</title>
    <link rel="stylesheet" href="/static/css/style.css">
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    {{ block "head" . }}{{ end }}
</head>
<body>
    {{ template "nav" . }}

    <main class="container">
        {{ block "content" . }}{{ end }}
    </main>

    <!-- Modal container for HTMX -->
    <div id="modal"></div>

    <script src="/static/js/app.js"></script>
    {{ block "scripts" . }}{{ end }}
</body>
</html>

Navigation Component

pkg/appview/templates/components/nav.html:

{{ define "nav" }}
<nav class="navbar">
    <div class="nav-brand">
        <a href="/ui/">ATCR</a>
    </div>

    <div class="nav-search">
        <form hx-get="/ui/api/recent-pushes"
              hx-target="#content"
              hx-trigger="submit"
              hx-include="[name='q']">
            <input type="text" name="q" placeholder="Search images..." />
        </form>
    </div>

    <div class="nav-links">
        {{ if .User }}
            <a href="/ui/images">Your Images</a>
            <span class="user-handle">@{{ .User.Handle }}</span>
            <a href="/ui/settings" class="settings-icon">⚙️</a>
            <form action="/auth/logout" method="POST" style="display: inline;">
                <button type="submit">Logout</button>
            </form>
        {{ else }}
            <a href="/auth/oauth/login?return_to=/ui/">Login</a>
        {{ end }}
    </div>
</nav>
{{ end }}

Step 3: Front Page (Homepage)

pkg/appview/templates/pages/home.html:

{{ define "title" }}ATCR - Federated Container Registry{{ end }}

{{ define "content" }}
<div class="home-page">
    <h1>Recent Pushes</h1>

    <div class="filters">
        <button hx-get="/ui/api/recent-pushes"
                hx-target="#push-list"
                hx-swap="innerHTML">All</button>
        <!-- Add more filter buttons as needed -->
    </div>

    <div id="push-list"
         hx-get="/ui/api/recent-pushes"
         hx-trigger="load, every 30s"
         hx-swap="innerHTML">
        <!-- Initial loading state -->
        <div class="loading">Loading recent pushes...</div>
    </div>
</div>
{{ end }}

pkg/appview/templates/partials/push-list.html:

{{ range .Pushes }}
<div class="push-card">
    <div class="push-header">
        <a href="/ui/?user={{ .Handle }}" class="push-user">{{ .Handle }}</a>
        <span class="push-separator">/</span>
        <span class="push-repo">{{ .Repository }}</span>
        <span class="push-separator">:</span>
        <span class="push-tag">{{ .Tag }}</span>
    </div>

    <div class="push-details">
        <code class="digest">{{ printf "%.12s" .Digest }}...</code>
        <span class="separator"></span>
        <span class="hold">{{ .HoldEndpoint }}</span>
        <span class="separator"></span>
        <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
            {{ .CreatedAt | timeAgo }}
        </time>
    </div>

    <div class="push-command">
        <code class="pull-command">docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code>
        <button class="copy-btn"
                onclick="copyToClipboard('docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')">
            📋 Copy
        </button>
    </div>

    <button class="view-manifest-btn"
            hx-get="/ui/api/manifests/{{ .Digest }}"
            hx-target="#modal"
            hx-swap="innerHTML">
        View Manifest
    </button>
</div>
{{ end }}

{{ if .HasMore }}
<button class="load-more"
        hx-get="/ui/api/recent-pushes?offset={{ .NextOffset }}"
        hx-target="#push-list"
        hx-swap="beforeend">
    Load More
</button>
{{ end }}

pkg/appview/handlers/home.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:

{{ define "title" }}Settings - ATCR{{ end }}

{{ define "content" }}
<div class="settings-page">
    <h1>Settings</h1>

    <!-- Identity Section -->
    <section class="settings-section">
        <h2>Identity</h2>
        <div class="form-group">
            <label>Handle:</label>
            <span>{{ .Profile.Handle }}</span>
        </div>
        <div class="form-group">
            <label>DID:</label>
            <code>{{ .Profile.DID }}</code>
        </div>
        <div class="form-group">
            <label>PDS:</label>
            <span>{{ .Profile.PDSEndpoint }}</span>
        </div>
    </section>

    <!-- Default Hold Section -->
    <section class="settings-section">
        <h2>Default Hold</h2>
        <p>Current: <strong>{{ .Profile.DefaultHold }}</strong></p>

        <form hx-post="/ui/api/profile/default-hold"
              hx-target="#hold-status"
              hx-swap="innerHTML">

            <div class="form-group">
                <label for="hold-select">Select from your holds:</label>
                <select name="hold_endpoint" id="hold-select">
                    {{ range .Holds }}
                    <option value="{{ .Endpoint }}"
                            {{ if eq .Endpoint $.Profile.DefaultHold }}selected{{ end }}>
                        {{ .Endpoint }} {{ if .Name }}({{ .Name }}){{ end }}
                    </option>
                    {{ end }}
                    <option value="">Custom URL...</option>
                </select>
            </div>

            <div class="form-group" id="custom-hold-group" style="display: none;">
                <label for="custom-hold">Custom hold URL:</label>
                <input type="text"
                       id="custom-hold"
                       name="custom_hold"
                       placeholder="https://hold.example.com" />
            </div>

            <button type="submit">Save</button>
        </form>

        <div id="hold-status"></div>
    </section>

    <!-- OAuth Session Section -->
    <section class="settings-section">
        <h2>OAuth Session</h2>
        <div class="form-group">
            <label>Logged in as:</label>
            <span>{{ .Profile.Handle }}</span>
        </div>
        <div class="form-group">
            <label>Session expires:</label>
            <time datetime="{{ .SessionExpiry.Format "2006-01-02T15:04:05Z07:00" }}">
                {{ .SessionExpiry.Format "2006-01-02 15:04:05 MST" }}
            </time>
        </div>
        <a href="/auth/oauth/login?return_to=/ui/settings" class="btn">Re-authenticate</a>
    </section>
</div>
{{ end }}

{{ define "scripts" }}
<script>
    // Show/hide custom URL field
    document.getElementById('hold-select').addEventListener('change', function(e) {
        const customGroup = document.getElementById('custom-hold-group');
        if (e.target.value === '') {
            customGroup.style.display = 'block';
        } else {
            customGroup.style.display = 'none';
        }
    });
</script>
{{ end }}

pkg/appview/handlers/settings.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(`<div class="error">Failed to update: ` + err.Error() + `</div>`))
        return
    }

    w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`))
}

Step 5: Personal Images Page

pkg/appview/templates/pages/images.html:

{{ define "title" }}Your Images - ATCR{{ end }}

{{ define "content" }}
<div class="images-page">
    <h1>Your Images</h1>

    {{ if .Repositories }}
        {{ range .Repositories }}
        <div class="repository-card">
            <div class="repo-header"
                 hx-get="/ui/api/repositories/{{ .Name }}/toggle"
                 hx-target="#repo-{{ .Name }}"
                 hx-swap="outerHTML">
                <h2>{{ .Name }}</h2>
                <div class="repo-stats">
                    <span>{{ .TagCount }} tags</span>
                    <span></span>
                    <span>{{ .ManifestCount }} manifests</span>
                    <span></span>
                    <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}">
                        Last push: {{ .LastPush | timeAgo }}
                    </time>
                </div>
                <button class="expand-btn"></button>
            </div>

            <div id="repo-{{ .Name }}" class="repo-details" style="display: none;">
                <!-- Tags Section -->
                <div class="tags-section">
                    <h3>Tags</h3>
                    {{ range .Tags }}
                    <div class="tag-row" id="tag-{{ $.Name }}-{{ .Tag }}">
                        <span class="tag-name">{{ .Tag }}</span>
                        <span class="tag-arrow"></span>
                        <code class="tag-digest">{{ printf "%.12s" .Digest }}...</code>
                        <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
                            {{ .CreatedAt | timeAgo }}
                        </time>

                        <button class="edit-btn"
                                hx-get="/ui/modals/edit-tag?repo={{ $.Name }}&tag={{ .Tag }}"
                                hx-target="#modal">
                            ✏️
                        </button>

                        <button class="delete-btn"
                                hx-delete="/ui/api/images/{{ $.Name }}/tags/{{ .Tag }}"
                                hx-confirm="Delete tag {{ .Tag }}?"
                                hx-target="#tag-{{ $.Name }}-{{ .Tag }}"
                                hx-swap="outerHTML">
                            🗑️
                        </button>
                    </div>
                    {{ end }}
                </div>

                <!-- Manifests Section -->
                <div class="manifests-section">
                    <h3>Manifests</h3>
                    {{ range .Manifests }}
                    <div class="manifest-row" id="manifest-{{ .Digest }}">
                        <code class="manifest-digest">{{ printf "%.12s" .Digest }}...</code>
                        <span>{{ .Size | humanizeBytes }}</span>
                        <span>{{ .HoldEndpoint }}</span>
                        <span>{{ .Architecture }}/{{ .OS }}</span>
                        <span>{{ .LayerCount }} layers</span>

                        <button class="view-btn"
                                hx-get="/ui/api/manifests/{{ .Digest }}"
                                hx-target="#modal">
                            View
                        </button>

                        {{ if not .Tagged }}
                        <button class="delete-btn"
                                hx-delete="/ui/api/images/{{ $.Name }}/manifests/{{ .Digest }}"
                                hx-confirm="Delete manifest {{ printf "%.12s" .Digest }}...?"
                                hx-target="#manifest-{{ .Digest }}"
                                hx-swap="outerHTML">
                            Delete
                        </button>
                        {{ end }}
                    </div>
                    {{ end }}
                </div>
            </div>
        </div>
        {{ end }}
    {{ else }}
        <div class="empty-state">
            <p>No images yet. Push your first image:</p>
            <code>docker push atcr.io/{{ .User.Handle }}/myapp:latest</code>
        </div>
    {{ end }}
</div>
{{ end }}

{{ define "scripts" }}
<script>
    // Toggle repository details
    document.querySelectorAll('.repo-header').forEach(header => {
        header.addEventListener('click', function() {
            const details = this.nextElementSibling;
            const btn = this.querySelector('.expand-btn');

            if (details.style.display === 'none') {
                details.style.display = 'block';
                btn.textContent = '▲';
            } else {
                details.style.display = 'none';
                btn.textContent = '▼';
            }
        });
    });
</script>
{{ end }}

pkg/appview/handlers/images.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:

{{ define "manifest-modal" }}
<div class="modal-overlay" onclick="this.remove()">
    <div class="modal-content" onclick="event.stopPropagation()">
        <button class="modal-close" onclick="this.closest('.modal-overlay').remove()"></button>

        <h2>Manifest Details</h2>

        <div class="manifest-info">
            <div class="info-row">
                <strong>Digest:</strong>
                <code>{{ .Digest }}</code>
            </div>
            <div class="info-row">
                <strong>Media Type:</strong>
                <span>{{ .MediaType }}</span>
            </div>
            <div class="info-row">
                <strong>Size:</strong>
                <span>{{ .Size | humanizeBytes }}</span>
            </div>
            <div class="info-row">
                <strong>Architecture:</strong>
                <span>{{ .Architecture }}/{{ .OS }}</span>
            </div>
            <div class="info-row">
                <strong>Created:</strong>
                <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
                    {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }}
                </time>
            </div>
            <div class="info-row">
                <strong>ATProto Record:</strong>
                <a href="at://{{ .DID }}/io.atcr.manifest/{{ .Rkey }}" target="_blank">
                    View on PDS
                </a>
            </div>
        </div>

        <h3>Layers</h3>
        <div class="layers-list">
            {{ range .Layers }}
            <div class="layer-row">
                <code>{{ .Digest }}</code>
                <span>{{ .Size | humanizeBytes }}</span>
                <span>{{ .MediaType }}</span>
            </div>
            {{ end }}
        </div>

        <h3>Raw Manifest</h3>
        <pre class="manifest-json"><code>{{ .RawManifest }}</code></pre>
    </div>
</div>
{{ end }}

pkg/appview/templates/partials/edit-tag-modal.html:

<div class="modal-overlay" onclick="this.remove()">
    <div class="modal-content" onclick="event.stopPropagation()">
        <button class="modal-close" onclick="this.closest('.modal-overlay').remove()"></button>

        <h2>Edit Tag: {{ .Tag }}</h2>

        <form hx-put="/ui/api/images/{{ .Repository }}/tags/{{ .Tag }}"
              hx-target="#tag-{{ .Repository }}-{{ .Tag }}"
              hx-swap="outerHTML">

            <div class="form-group">
                <label for="digest">Point to manifest:</label>
                <select name="digest" id="digest" required>
                    {{ range .Manifests }}
                    <option value="{{ .Digest }}"
                            {{ if eq .Digest $.CurrentDigest }}selected{{ end }}>
                        {{ printf "%.12s" .Digest }}... ({{ .CreatedAt | timeAgo }})
                    </option>
                    {{ end }}
                </select>
            </div>

            <button type="submit">Update Tag</button>
            <button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
        </form>
    </div>
</div>

Step 7: Authentication & Session

pkg/appview/session/session.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:

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):

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:

: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:

// 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):

// 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

# Run migrations
go run cmd/appview/main.go migrate

# Start server
go run cmd/appview/main.go serve

Production

# Build binary
go build -o bin/atcr-appview ./cmd/appview

# Run with config
./bin/atcr-appview serve config/production.yml

Environment Variables

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

// pkg/appview/appview.go
//go:embed templates/*.html templates/**/*.html
var templatesFS embed.FS

//go:embed static/*
var staticFS embed.FS

Build:

go build -o bin/atcr-appview ./cmd/appview

Deploy:

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.