A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

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.