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
- Prefetching: Add
hx-trigger="mouseenter"to links for hover prefetch - Caching: Use
hx-cache="true"for cacheable content - Optimistic updates: Remove elements immediately, rollback on error
- Debouncing: Add
delay:500msto search inputs
Database Optimizations
- Indexes: Already defined in schema (did, repo, created_at, digest)
- Connection pooling: Use
db.SetMaxOpenConns(25) - Prepared statements: Cache frequently used queries
- Batch inserts: For firehose events, batch into transactions
Template Optimizations
- Pre-parse: Parse templates once at startup, not per request
- Caching: Cache rendered partials for static content
- 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
- Add search: Implement full-text search on SQLite
- Public profiles:
/ui/@aliceshows public view - Manifest diff: Compare manifest versions
- Export data: Download all your images as JSON
- Webhook notifications: Alert on new pushes
- CLI integration:
atcr ui opento 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-appviewbinary contains everything - Easy deployment: just copy one file
Package Structure
pkg/appviewmakes 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
- Edit templates in
pkg/appview/templates/ - Edit CSS/JS in
pkg/appview/static/ - Run
go build- assets auto-embedded - 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.