A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1# ATCR AppView UI - Implementation Guide
2
3This document provides step-by-step implementation details for building the ATCR web UI using **html/template + HTMX**.
4
5## Tech Stack (Finalized)
6
7- **Backend:** Go (existing AppView)
8- **Templates:** `html/template` (standard library)
9- **Interactivity:** HTMX (~14KB) + Alpine.js (~15KB, optional)
10- **Database:** SQLite (firehose cache)
11- **Styling:** Simple CSS or Tailwind (TBD)
12- **Authentication:** OAuth (existing implementation)
13
14## Project Structure
15
16```
17cmd/appview/
18├── main.go # Add AppView routes here
19
20pkg/appview/
21├── appview.go # Main AppView setup, embed directives
22├── handlers/ # HTTP handlers
23│ ├── home.go # Front page (firehose)
24│ ├── settings.go # Settings page
25│ ├── images.go # Personal images page
26│ └── auth.go # Login/logout handlers
27├── db/ # Database layer
28│ ├── schema.go # SQLite schema
29│ ├── queries.go # DB queries
30│ └── models.go # Data models
31├── firehose/ # Firehose worker
32│ ├── worker.go # Background worker
33│ └── jetstream.go # Jetstream client
34├── middleware/ # HTTP middleware
35│ ├── auth.go # Session auth
36│ └── csrf.go # CSRF protection
37├── session/ # Session management
38│ └── session.go # Session store
39├── templates/ # HTML templates (embedded)
40│ ├── layouts/
41│ │ └── base.html # Base layout
42│ ├── components/
43│ │ ├── nav.html # Navigation bar
44│ │ └── modal.html # Modal dialogs
45│ ├── pages/
46│ │ ├── home.html # Front page
47│ │ ├── settings.html # Settings page
48│ │ └── images.html # Personal images
49│ └── partials/ # HTMX partials
50│ ├── push-list.html # Push list partial
51│ └── tag-row.html # Tag row partial
52└── static/ # Static assets (embedded)
53 ├── css/
54 │ └── style.css
55 └── js/
56 └── app.js # Minimal JS (clipboard, etc.)
57```
58
59## Step 1: Embed Setup
60
61### Main AppView Package
62
63**pkg/appview/appview.go:**
64
65```go
66package appview
67
68import (
69 "embed"
70 "html/template"
71 "io/fs"
72 "net/http"
73)
74
75//go:embed templates/*.html templates/**/*.html
76var templatesFS embed.FS
77
78//go:embed static/*
79var staticFS embed.FS
80
81// Templates returns parsed templates
82func Templates() (*template.Template, error) {
83 return template.ParseFS(templatesFS, "templates/**/*.html")
84}
85
86// StaticHandler returns HTTP handler for static files
87func StaticHandler() http.Handler {
88 sub, _ := fs.Sub(staticFS, "static")
89 return http.FileServer(http.FS(sub))
90}
91```
92
93## Step 2: Database Setup
94
95### Create Schema
96
97**pkg/appview/db/schema.go:**
98
99```go
100package db
101
102import (
103 "database/sql"
104 _ "github.com/mattn/go-sqlite3"
105)
106
107const schema = `
108CREATE TABLE IF NOT EXISTS users (
109 did TEXT PRIMARY KEY,
110 handle TEXT NOT NULL,
111 pds_endpoint TEXT NOT NULL,
112 last_seen TIMESTAMP NOT NULL,
113 UNIQUE(handle)
114);
115CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
116
117CREATE TABLE IF NOT EXISTS manifests (
118 id INTEGER PRIMARY KEY AUTOINCREMENT,
119 did TEXT NOT NULL,
120 repository TEXT NOT NULL,
121 digest TEXT NOT NULL,
122 hold_endpoint TEXT NOT NULL,
123 schema_version INTEGER NOT NULL,
124 media_type TEXT NOT NULL,
125 config_digest TEXT,
126 config_size INTEGER,
127 raw_manifest TEXT NOT NULL,
128 created_at TIMESTAMP NOT NULL,
129 UNIQUE(did, repository, digest),
130 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
131);
132CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
133CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
134CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
135
136CREATE TABLE IF NOT EXISTS layers (
137 manifest_id INTEGER NOT NULL,
138 digest TEXT NOT NULL,
139 size INTEGER NOT NULL,
140 media_type TEXT NOT NULL,
141 layer_index INTEGER NOT NULL,
142 PRIMARY KEY(manifest_id, layer_index),
143 FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
144);
145CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest);
146
147CREATE TABLE IF NOT EXISTS tags (
148 id INTEGER PRIMARY KEY AUTOINCREMENT,
149 did TEXT NOT NULL,
150 repository TEXT NOT NULL,
151 tag TEXT NOT NULL,
152 digest TEXT NOT NULL,
153 created_at TIMESTAMP NOT NULL,
154 UNIQUE(did, repository, tag),
155 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
156);
157CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository);
158
159CREATE TABLE IF NOT EXISTS firehose_cursor (
160 id INTEGER PRIMARY KEY CHECK (id = 1),
161 cursor INTEGER NOT NULL,
162 updated_at TIMESTAMP NOT NULL
163);
164`
165
166func InitDB(path string) (*sql.DB, error) {
167 db, err := sql.Open("sqlite3", path)
168 if err != nil {
169 return nil, err
170 }
171
172 if _, err := db.Exec(schema); err != nil {
173 return nil, err
174 }
175
176 return db, nil
177}
178```
179
180### Data Models
181
182**pkg/appview/db/models.go:**
183
184```go
185package db
186
187import "time"
188
189type User struct {
190 DID string
191 Handle string
192 PDSEndpoint string
193 LastSeen time.Time
194}
195
196type Manifest struct {
197 ID int64
198 DID string
199 Repository string
200 Digest string
201 HoldEndpoint string
202 SchemaVersion int
203 MediaType string
204 ConfigDigest string
205 ConfigSize int64
206 RawManifest string // JSON
207 CreatedAt time.Time
208}
209
210type Tag struct {
211 ID int64
212 DID string
213 Repository string
214 Tag string
215 Digest string
216 CreatedAt time.Time
217}
218
219type Push struct {
220 Handle string
221 Repository string
222 Tag string
223 Digest string
224 HoldEndpoint string
225 CreatedAt time.Time
226}
227
228type Repository struct {
229 Name string
230 TagCount int
231 ManifestCount int
232 LastPush time.Time
233 Tags []Tag
234 Manifests []Manifest
235}
236```
237
238### Query Functions
239
240**pkg/appview/db/queries.go:**
241
242```go
243package db
244
245import (
246 "database/sql"
247 "time"
248)
249
250// GetRecentPushes fetches recent pushes with pagination
251func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) {
252 query := `
253 SELECT u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at
254 FROM tags t
255 JOIN users u ON t.did = u.did
256 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
257 `
258
259 if userFilter != "" {
260 query += " WHERE u.handle = ? OR u.did = ?"
261 }
262
263 query += " ORDER BY t.created_at DESC LIMIT ? OFFSET ?"
264
265 var rows *sql.Rows
266 var err error
267
268 if userFilter != "" {
269 rows, err = db.Query(query, userFilter, userFilter, limit, offset)
270 } else {
271 rows, err = db.Query(query, limit, offset)
272 }
273
274 if err != nil {
275 return nil, 0, err
276 }
277 defer rows.Close()
278
279 var pushes []Push
280 for rows.Next() {
281 var p Push
282 if err := rows.Scan(&p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil {
283 return nil, 0, err
284 }
285 pushes = append(pushes, p)
286 }
287
288 // Get total count
289 countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did"
290 if userFilter != "" {
291 countQuery += " WHERE u.handle = ? OR u.did = ?"
292 }
293
294 var total int
295 if userFilter != "" {
296 db.QueryRow(countQuery, userFilter, userFilter).Scan(&total)
297 } else {
298 db.QueryRow(countQuery).Scan(&total)
299 }
300
301 return pushes, total, nil
302}
303
304// GetUserRepositories fetches all repositories for a user
305func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) {
306 // Get repository summary
307 rows, err := db.Query(`
308 SELECT
309 repository,
310 COUNT(DISTINCT tag) as tag_count,
311 COUNT(DISTINCT digest) as manifest_count,
312 MAX(created_at) as last_push
313 FROM (
314 SELECT repository, tag, digest, created_at FROM tags WHERE did = ?
315 UNION
316 SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ?
317 )
318 GROUP BY repository
319 ORDER BY last_push DESC
320 `, did, did)
321
322 if err != nil {
323 return nil, err
324 }
325 defer rows.Close()
326
327 var repos []Repository
328 for rows.Next() {
329 var r Repository
330 if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &r.LastPush); err != nil {
331 return nil, err
332 }
333
334 // Get tags for this repo
335 tagRows, err := db.Query(`
336 SELECT tag, digest, created_at
337 FROM tags
338 WHERE did = ? AND repository = ?
339 ORDER BY created_at DESC
340 `, did, r.Name)
341
342 if err != nil {
343 return nil, err
344 }
345
346 for tagRows.Next() {
347 var t Tag
348 if err := tagRows.Scan(&t.Tag, &t.Digest, &t.CreatedAt); err != nil {
349 tagRows.Close()
350 return nil, err
351 }
352 r.Tags = append(r.Tags, t)
353 }
354 tagRows.Close()
355
356 // Get manifests for this repo
357 manifestRows, err := db.Query(`
358 SELECT id, digest, hold_endpoint, schema_version, media_type,
359 config_digest, config_size, raw_manifest, created_at
360 FROM manifests
361 WHERE did = ? AND repository = ?
362 ORDER BY created_at DESC
363 `, did, r.Name)
364
365 if err != nil {
366 return nil, err
367 }
368
369 for manifestRows.Next() {
370 var m Manifest
371 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
372 &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt); err != nil {
373 manifestRows.Close()
374 return nil, err
375 }
376 r.Manifests = append(r.Manifests, m)
377 }
378 manifestRows.Close()
379
380 repos = append(repos, r)
381 }
382
383 return repos, nil
384}
385```
386
387## Step 2: Templates Layout
388
389### Base Layout
390
391**pkg/appview/templates/layouts/base.html:**
392
393```html
394<!DOCTYPE html>
395<html lang="en">
396<head>
397 <meta charset="UTF-8">
398 <meta name="viewport" content="width=device-width, initial-scale=1.0">
399 <title>{{ block "title" . }}ATCR{{ end }}</title>
400 <link rel="stylesheet" href="/static/css/style.css">
401 <script src="https://unpkg.com/htmx.org@1.9.10"></script>
402 {{ block "head" . }}{{ end }}
403</head>
404<body>
405 {{ template "nav" . }}
406
407 <main class="container">
408 {{ block "content" . }}{{ end }}
409 </main>
410
411 <!-- Modal container for HTMX -->
412 <div id="modal"></div>
413
414 <script src="/static/js/app.js"></script>
415 {{ block "scripts" . }}{{ end }}
416</body>
417</html>
418```
419
420### Navigation Component
421
422**pkg/appview/templates/components/nav.html:**
423
424```html
425{{ define "nav" }}
426<nav class="navbar">
427 <div class="nav-brand">
428 <a href="/ui/">ATCR</a>
429 </div>
430
431 <div class="nav-search">
432 <form hx-get="/ui/api/recent-pushes"
433 hx-target="#content"
434 hx-trigger="submit"
435 hx-include="[name='q']">
436 <input type="text" name="q" placeholder="Search images..." />
437 </form>
438 </div>
439
440 <div class="nav-links">
441 {{ if .User }}
442 <a href="/ui/images">Your Images</a>
443 <span class="user-handle">@{{ .User.Handle }}</span>
444 <a href="/ui/settings" class="settings-icon">⚙️</a>
445 <form action="/auth/logout" method="POST" style="display: inline;">
446 <button type="submit">Logout</button>
447 </form>
448 {{ else }}
449 <a href="/auth/oauth/login?return_to=/ui/">Login</a>
450 {{ end }}
451 </div>
452</nav>
453{{ end }}
454```
455
456## Step 3: Front Page (Homepage)
457
458**pkg/appview/templates/pages/home.html:**
459
460```html
461{{ define "title" }}ATCR - Federated Container Registry{{ end }}
462
463{{ define "content" }}
464<div class="home-page">
465 <h1>Recent Pushes</h1>
466
467 <div class="filters">
468 <button hx-get="/ui/api/recent-pushes"
469 hx-target="#push-list"
470 hx-swap="innerHTML">All</button>
471 <!-- Add more filter buttons as needed -->
472 </div>
473
474 <div id="push-list"
475 hx-get="/ui/api/recent-pushes"
476 hx-trigger="load, every 30s"
477 hx-swap="innerHTML">
478 <!-- Initial loading state -->
479 <div class="loading">Loading recent pushes...</div>
480 </div>
481</div>
482{{ end }}
483```
484
485**pkg/appview/templates/partials/push-list.html:**
486
487```html
488{{ range .Pushes }}
489<div class="push-card">
490 <div class="push-header">
491 <a href="/ui/?user={{ .Handle }}" class="push-user">{{ .Handle }}</a>
492 <span class="push-separator">/</span>
493 <span class="push-repo">{{ .Repository }}</span>
494 <span class="push-separator">:</span>
495 <span class="push-tag">{{ .Tag }}</span>
496 </div>
497
498 <div class="push-details">
499 <code class="digest">{{ printf "%.12s" .Digest }}...</code>
500 <span class="separator">•</span>
501 <span class="hold">{{ .HoldEndpoint }}</span>
502 <span class="separator">•</span>
503 <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
504 {{ .CreatedAt | timeAgo }}
505 </time>
506 </div>
507
508 <div class="push-command">
509 <code class="pull-command">docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code>
510 <button class="copy-btn"
511 onclick="copyToClipboard('docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')">
512 📋 Copy
513 </button>
514 </div>
515
516 <button class="view-manifest-btn"
517 hx-get="/ui/api/manifests/{{ .Digest }}"
518 hx-target="#modal"
519 hx-swap="innerHTML">
520 View Manifest
521 </button>
522</div>
523{{ end }}
524
525{{ if .HasMore }}
526<button class="load-more"
527 hx-get="/ui/api/recent-pushes?offset={{ .NextOffset }}"
528 hx-target="#push-list"
529 hx-swap="beforeend">
530 Load More
531</button>
532{{ end }}
533```
534
535**pkg/appview/handlers/home.go:**
536
537```go
538package handlers
539
540import (
541 "html/template"
542 "net/http"
543 "strconv"
544 "atcr.io/pkg/appview/db"
545)
546
547type HomeHandler struct {
548 DB *sql.DB
549 Templates *template.Template
550}
551
552func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
553 // Check if this is an HTMX request for the partial
554 if r.Header.Get("HX-Request") == "true" {
555 h.servePushList(w, r)
556 return
557 }
558
559 // Serve full page
560 data := struct {
561 User *db.User
562 }{
563 User: getUserFromContext(r),
564 }
565
566 h.Templates.ExecuteTemplate(w, "home.html", data)
567}
568
569func (h *HomeHandler) servePushList(w http.ResponseWriter, r *http.Request) {
570 limit := 50
571 offset := 0
572
573 if o := r.URL.Query().Get("offset"); o != "" {
574 offset, _ = strconv.Atoi(o)
575 }
576
577 userFilter := r.URL.Query().Get("user")
578
579 pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter)
580 if err != nil {
581 http.Error(w, err.Error(), http.StatusInternalServerError)
582 return
583 }
584
585 data := struct {
586 Pushes []db.Push
587 HasMore bool
588 NextOffset int
589 }{
590 Pushes: pushes,
591 HasMore: offset+limit < total,
592 NextOffset: offset + limit,
593 }
594
595 h.Templates.ExecuteTemplate(w, "push-list.html", data)
596}
597```
598
599## Step 4: Settings Page
600
601**pkg/appview/templates/pages/settings.html:**
602
603```html
604{{ define "title" }}Settings - ATCR{{ end }}
605
606{{ define "content" }}
607<div class="settings-page">
608 <h1>Settings</h1>
609
610 <!-- Identity Section -->
611 <section class="settings-section">
612 <h2>Identity</h2>
613 <div class="form-group">
614 <label>Handle:</label>
615 <span>{{ .Profile.Handle }}</span>
616 </div>
617 <div class="form-group">
618 <label>DID:</label>
619 <code>{{ .Profile.DID }}</code>
620 </div>
621 <div class="form-group">
622 <label>PDS:</label>
623 <span>{{ .Profile.PDSEndpoint }}</span>
624 </div>
625 </section>
626
627 <!-- Default Hold Section -->
628 <section class="settings-section">
629 <h2>Default Hold</h2>
630 <p>Current: <strong>{{ .Profile.DefaultHold }}</strong></p>
631
632 <form hx-post="/ui/api/profile/default-hold"
633 hx-target="#hold-status"
634 hx-swap="innerHTML">
635
636 <div class="form-group">
637 <label for="hold-select">Select from your holds:</label>
638 <select name="hold_endpoint" id="hold-select">
639 {{ range .Holds }}
640 <option value="{{ .Endpoint }}"
641 {{ if eq .Endpoint $.Profile.DefaultHold }}selected{{ end }}>
642 {{ .Endpoint }} {{ if .Name }}({{ .Name }}){{ end }}
643 </option>
644 {{ end }}
645 <option value="">Custom URL...</option>
646 </select>
647 </div>
648
649 <div class="form-group" id="custom-hold-group" style="display: none;">
650 <label for="custom-hold">Custom hold URL:</label>
651 <input type="text"
652 id="custom-hold"
653 name="custom_hold"
654 placeholder="https://hold.example.com" />
655 </div>
656
657 <button type="submit">Save</button>
658 </form>
659
660 <div id="hold-status"></div>
661 </section>
662
663 <!-- OAuth Session Section -->
664 <section class="settings-section">
665 <h2>OAuth Session</h2>
666 <div class="form-group">
667 <label>Logged in as:</label>
668 <span>{{ .Profile.Handle }}</span>
669 </div>
670 <div class="form-group">
671 <label>Session expires:</label>
672 <time datetime="{{ .SessionExpiry.Format "2006-01-02T15:04:05Z07:00" }}">
673 {{ .SessionExpiry.Format "2006-01-02 15:04:05 MST" }}
674 </time>
675 </div>
676 <a href="/auth/oauth/login?return_to=/ui/settings" class="btn">Re-authenticate</a>
677 </section>
678</div>
679{{ end }}
680
681{{ define "scripts" }}
682<script>
683 // Show/hide custom URL field
684 document.getElementById('hold-select').addEventListener('change', function(e) {
685 const customGroup = document.getElementById('custom-hold-group');
686 if (e.target.value === '') {
687 customGroup.style.display = 'block';
688 } else {
689 customGroup.style.display = 'none';
690 }
691 });
692</script>
693{{ end }}
694```
695
696**pkg/appview/handlers/settings.go:**
697
698```go
699package handlers
700
701import (
702 "database/sql"
703 "encoding/json"
704 "html/template"
705 "net/http"
706 "atcr.io/pkg/atproto"
707)
708
709type SettingsHandler struct {
710 Templates *template.Template
711 ATProtoClient *atproto.Client
712}
713
714func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
715 user := getUserFromContext(r)
716 if user == nil {
717 http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/settings", http.StatusFound)
718 return
719 }
720
721 // Fetch user profile from PDS
722 profile, err := h.ATProtoClient.GetProfile(user.DID)
723 if err != nil {
724 http.Error(w, err.Error(), http.StatusInternalServerError)
725 return
726 }
727
728 // Fetch user's holds
729 holds, err := h.ATProtoClient.ListHolds(user.DID)
730 if err != nil {
731 http.Error(w, err.Error(), http.StatusInternalServerError)
732 return
733 }
734
735 data := struct {
736 Profile *atproto.SailorProfileRecord
737 Holds []atproto.HoldRecord
738 SessionExpiry time.Time
739 }{
740 Profile: profile,
741 Holds: holds,
742 SessionExpiry: getSessionExpiry(r),
743 }
744
745 h.Templates.ExecuteTemplate(w, "settings.html", data)
746}
747
748func (h *SettingsHandler) UpdateDefaultHold(w http.ResponseWriter, r *http.Request) {
749 user := getUserFromContext(r)
750 if user == nil {
751 http.Error(w, "Unauthorized", http.StatusUnauthorized)
752 return
753 }
754
755 holdEndpoint := r.FormValue("hold_endpoint")
756 if holdEndpoint == "" {
757 holdEndpoint = r.FormValue("custom_hold")
758 }
759
760 // Update profile in PDS
761 err := h.ATProtoClient.UpdateProfile(user.DID, map[string]any{
762 "defaultHold": holdEndpoint,
763 })
764
765 if err != nil {
766 w.Write([]byte(`<div class="error">Failed to update: ` + err.Error() + `</div>`))
767 return
768 }
769
770 w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`))
771}
772```
773
774## Step 5: Personal Images Page
775
776**pkg/appview/templates/pages/images.html:**
777
778```html
779{{ define "title" }}Your Images - ATCR{{ end }}
780
781{{ define "content" }}
782<div class="images-page">
783 <h1>Your Images</h1>
784
785 {{ if .Repositories }}
786 {{ range .Repositories }}
787 <div class="repository-card">
788 <div class="repo-header"
789 hx-get="/ui/api/repositories/{{ .Name }}/toggle"
790 hx-target="#repo-{{ .Name }}"
791 hx-swap="outerHTML">
792 <h2>{{ .Name }}</h2>
793 <div class="repo-stats">
794 <span>{{ .TagCount }} tags</span>
795 <span>•</span>
796 <span>{{ .ManifestCount }} manifests</span>
797 <span>•</span>
798 <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}">
799 Last push: {{ .LastPush | timeAgo }}
800 </time>
801 </div>
802 <button class="expand-btn">▼</button>
803 </div>
804
805 <div id="repo-{{ .Name }}" class="repo-details" style="display: none;">
806 <!-- Tags Section -->
807 <div class="tags-section">
808 <h3>Tags</h3>
809 {{ range .Tags }}
810 <div class="tag-row" id="tag-{{ $.Name }}-{{ .Tag }}">
811 <span class="tag-name">{{ .Tag }}</span>
812 <span class="tag-arrow">→</span>
813 <code class="tag-digest">{{ printf "%.12s" .Digest }}...</code>
814 <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
815 {{ .CreatedAt | timeAgo }}
816 </time>
817
818 <button class="edit-btn"
819 hx-get="/ui/modals/edit-tag?repo={{ $.Name }}&tag={{ .Tag }}"
820 hx-target="#modal">
821 ✏️
822 </button>
823
824 <button class="delete-btn"
825 hx-delete="/ui/api/images/{{ $.Name }}/tags/{{ .Tag }}"
826 hx-confirm="Delete tag {{ .Tag }}?"
827 hx-target="#tag-{{ $.Name }}-{{ .Tag }}"
828 hx-swap="outerHTML">
829 🗑️
830 </button>
831 </div>
832 {{ end }}
833 </div>
834
835 <!-- Manifests Section -->
836 <div class="manifests-section">
837 <h3>Manifests</h3>
838 {{ range .Manifests }}
839 <div class="manifest-row" id="manifest-{{ .Digest }}">
840 <code class="manifest-digest">{{ printf "%.12s" .Digest }}...</code>
841 <span>{{ .Size | humanizeBytes }}</span>
842 <span>{{ .HoldEndpoint }}</span>
843 <span>{{ .Architecture }}/{{ .OS }}</span>
844 <span>{{ .LayerCount }} layers</span>
845
846 <button class="view-btn"
847 hx-get="/ui/api/manifests/{{ .Digest }}"
848 hx-target="#modal">
849 View
850 </button>
851
852 {{ if not .Tagged }}
853 <button class="delete-btn"
854 hx-delete="/ui/api/images/{{ $.Name }}/manifests/{{ .Digest }}"
855 hx-confirm="Delete manifest {{ printf "%.12s" .Digest }}...?"
856 hx-target="#manifest-{{ .Digest }}"
857 hx-swap="outerHTML">
858 Delete
859 </button>
860 {{ end }}
861 </div>
862 {{ end }}
863 </div>
864 </div>
865 </div>
866 {{ end }}
867 {{ else }}
868 <div class="empty-state">
869 <p>No images yet. Push your first image:</p>
870 <code>docker push atcr.io/{{ .User.Handle }}/myapp:latest</code>
871 </div>
872 {{ end }}
873</div>
874{{ end }}
875
876{{ define "scripts" }}
877<script>
878 // Toggle repository details
879 document.querySelectorAll('.repo-header').forEach(header => {
880 header.addEventListener('click', function() {
881 const details = this.nextElementSibling;
882 const btn = this.querySelector('.expand-btn');
883
884 if (details.style.display === 'none') {
885 details.style.display = 'block';
886 btn.textContent = '▲';
887 } else {
888 details.style.display = 'none';
889 btn.textContent = '▼';
890 }
891 });
892 });
893</script>
894{{ end }}
895```
896
897**pkg/appview/handlers/images.go:**
898
899```go
900package handlers
901
902import (
903 "database/sql"
904 "html/template"
905 "net/http"
906 "atcr.io/pkg/appview/db"
907 "atcr.io/pkg/atproto"
908)
909
910type ImagesHandler struct {
911 DB *sql.DB
912 Templates *template.Template
913 ATProtoClient *atproto.Client
914}
915
916func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
917 user := getUserFromContext(r)
918 if user == nil {
919 http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound)
920 return
921 }
922
923 // Fetch repositories from PDS (user's own data)
924 repos, err := h.ATProtoClient.ListRepositories(user.DID)
925 if err != nil {
926 http.Error(w, err.Error(), http.StatusInternalServerError)
927 return
928 }
929
930 data := struct {
931 User *db.User
932 Repositories []db.Repository
933 }{
934 User: user,
935 Repositories: repos,
936 }
937
938 h.Templates.ExecuteTemplate(w, "images.html", data)
939}
940
941func (h *ImagesHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
942 user := getUserFromContext(r)
943 if user == nil {
944 http.Error(w, "Unauthorized", http.StatusUnauthorized)
945 return
946 }
947
948 // Extract repo and tag from URL
949 vars := mux.Vars(r)
950 repo := vars["repository"]
951 tag := vars["tag"]
952
953 // Delete tag record from PDS
954 err := h.ATProtoClient.DeleteTag(user.DID, repo, tag)
955 if err != nil {
956 http.Error(w, err.Error(), http.StatusInternalServerError)
957 return
958 }
959
960 // Return empty response (HTMX will swap out the element)
961 w.WriteHeader(http.StatusOK)
962}
963
964func (h *ImagesHandler) DeleteManifest(w http.ResponseWriter, r *http.Request) {
965 user := getUserFromContext(r)
966 if user == nil {
967 http.Error(w, "Unauthorized", http.StatusUnauthorized)
968 return
969 }
970
971 vars := mux.Vars(r)
972 repo := vars["repository"]
973 digest := vars["digest"]
974
975 // Check if manifest is tagged
976 tagged, err := h.ATProtoClient.IsManifestTagged(user.DID, repo, digest)
977 if err != nil {
978 http.Error(w, err.Error(), http.StatusInternalServerError)
979 return
980 }
981
982 if tagged {
983 http.Error(w, "Cannot delete tagged manifest", http.StatusBadRequest)
984 return
985 }
986
987 // Delete manifest from PDS
988 err = h.ATProtoClient.DeleteManifest(user.DID, repo, digest)
989 if err != nil {
990 http.Error(w, err.Error(), http.StatusInternalServerError)
991 return
992 }
993
994 w.WriteHeader(http.StatusOK)
995}
996```
997
998## Step 6: Modals & Partials
999
1000**pkg/appview/templates/components/modal.html:**
1001
1002```html
1003{{ define "manifest-modal" }}
1004<div class="modal-overlay" onclick="this.remove()">
1005 <div class="modal-content" onclick="event.stopPropagation()">
1006 <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button>
1007
1008 <h2>Manifest Details</h2>
1009
1010 <div class="manifest-info">
1011 <div class="info-row">
1012 <strong>Digest:</strong>
1013 <code>{{ .Digest }}</code>
1014 </div>
1015 <div class="info-row">
1016 <strong>Media Type:</strong>
1017 <span>{{ .MediaType }}</span>
1018 </div>
1019 <div class="info-row">
1020 <strong>Size:</strong>
1021 <span>{{ .Size | humanizeBytes }}</span>
1022 </div>
1023 <div class="info-row">
1024 <strong>Architecture:</strong>
1025 <span>{{ .Architecture }}/{{ .OS }}</span>
1026 </div>
1027 <div class="info-row">
1028 <strong>Created:</strong>
1029 <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
1030 {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }}
1031 </time>
1032 </div>
1033 <div class="info-row">
1034 <strong>ATProto Record:</strong>
1035 <a href="at://{{ .DID }}/io.atcr.manifest/{{ .Rkey }}" target="_blank">
1036 View on PDS
1037 </a>
1038 </div>
1039 </div>
1040
1041 <h3>Layers</h3>
1042 <div class="layers-list">
1043 {{ range .Layers }}
1044 <div class="layer-row">
1045 <code>{{ .Digest }}</code>
1046 <span>{{ .Size | humanizeBytes }}</span>
1047 <span>{{ .MediaType }}</span>
1048 </div>
1049 {{ end }}
1050 </div>
1051
1052 <h3>Raw Manifest</h3>
1053 <pre class="manifest-json"><code>{{ .RawManifest }}</code></pre>
1054 </div>
1055</div>
1056{{ end }}
1057```
1058
1059**pkg/appview/templates/partials/edit-tag-modal.html:**
1060
1061```html
1062<div class="modal-overlay" onclick="this.remove()">
1063 <div class="modal-content" onclick="event.stopPropagation()">
1064 <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button>
1065
1066 <h2>Edit Tag: {{ .Tag }}</h2>
1067
1068 <form hx-put="/ui/api/images/{{ .Repository }}/tags/{{ .Tag }}"
1069 hx-target="#tag-{{ .Repository }}-{{ .Tag }}"
1070 hx-swap="outerHTML">
1071
1072 <div class="form-group">
1073 <label for="digest">Point to manifest:</label>
1074 <select name="digest" id="digest" required>
1075 {{ range .Manifests }}
1076 <option value="{{ .Digest }}"
1077 {{ if eq .Digest $.CurrentDigest }}selected{{ end }}>
1078 {{ printf "%.12s" .Digest }}... ({{ .CreatedAt | timeAgo }})
1079 </option>
1080 {{ end }}
1081 </select>
1082 </div>
1083
1084 <button type="submit">Update Tag</button>
1085 <button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
1086 </form>
1087 </div>
1088</div>
1089```
1090
1091## Step 7: Authentication & Session
1092
1093**pkg/appview/session/session.go:**
1094
1095```go
1096package session
1097
1098import (
1099 "crypto/rand"
1100 "encoding/base64"
1101 "net/http"
1102 "sync"
1103 "time"
1104)
1105
1106type Session struct {
1107 ID string
1108 DID string
1109 Handle string
1110 ExpiresAt time.Time
1111}
1112
1113type Store struct {
1114 mu sync.RWMutex
1115 sessions map[string]*Session
1116}
1117
1118func NewStore() *Store {
1119 return &Store{
1120 sessions: make(map[string]*Session),
1121 }
1122}
1123
1124func (s *Store) Create(did, handle string, duration time.Duration) (*Session, error) {
1125 s.mu.Lock()
1126 defer s.mu.Unlock()
1127
1128 // Generate random session ID
1129 b := make([]byte, 32)
1130 if _, err := rand.Read(b); err != nil {
1131 return nil, err
1132 }
1133
1134 sess := &Session{
1135 ID: base64.URLEncoding.EncodeToString(b),
1136 DID: did,
1137 Handle: handle,
1138 ExpiresAt: time.Now().Add(duration),
1139 }
1140
1141 s.sessions[sess.ID] = sess
1142 return sess, nil
1143}
1144
1145func (s *Store) Get(id string) (*Session, bool) {
1146 s.mu.RLock()
1147 defer s.mu.RUnlock()
1148
1149 sess, ok := s.sessions[id]
1150 if !ok || time.Now().After(sess.ExpiresAt) {
1151 return nil, false
1152 }
1153
1154 return sess, true
1155}
1156
1157func (s *Store) Delete(id string) {
1158 s.mu.Lock()
1159 defer s.mu.Unlock()
1160
1161 delete(s.sessions, id)
1162}
1163
1164func (s *Store) Cleanup() {
1165 s.mu.Lock()
1166 defer s.mu.Unlock()
1167
1168 now := time.Now()
1169 for id, sess := range s.sessions {
1170 if now.After(sess.ExpiresAt) {
1171 delete(s.sessions, id)
1172 }
1173 }
1174}
1175
1176// SetCookie sets the session cookie
1177func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) {
1178 http.SetCookie(w, &http.Cookie{
1179 Name: "atcr_session",
1180 Value: sessionID,
1181 Path: "/",
1182 MaxAge: maxAge,
1183 HttpOnly: true,
1184 Secure: true,
1185 SameSite: http.SameSiteLaxMode,
1186 })
1187}
1188
1189// GetSessionID gets session ID from cookie
1190func GetSessionID(r *http.Request) (string, bool) {
1191 cookie, err := r.Cookie("atcr_session")
1192 if err != nil {
1193 return "", false
1194 }
1195 return cookie.Value, true
1196}
1197```
1198
1199**pkg/appview/middleware/auth.go:**
1200
1201```go
1202package middleware
1203
1204import (
1205 "context"
1206 "net/http"
1207 "atcr.io/pkg/appview/session"
1208 "atcr.io/pkg/appview/db"
1209)
1210
1211type contextKey string
1212
1213const userKey contextKey = "user"
1214
1215func RequireAuth(store *session.Store) func(http.Handler) http.Handler {
1216 return func(next http.Handler) http.Handler {
1217 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1218 sessionID, ok := session.GetSessionID(r)
1219 if !ok {
1220 http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound)
1221 return
1222 }
1223
1224 sess, ok := store.Get(sessionID)
1225 if !ok {
1226 http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound)
1227 return
1228 }
1229
1230 user := &db.User{
1231 DID: sess.DID,
1232 Handle: sess.Handle,
1233 }
1234
1235 ctx := context.WithValue(r.Context(), userKey, user)
1236 next.ServeHTTP(w, r.WithContext(ctx))
1237 })
1238 }
1239}
1240
1241func GetUser(r *http.Request) *db.User {
1242 user, ok := r.Context().Value(userKey).(*db.User)
1243 if !ok {
1244 return nil
1245 }
1246 return user
1247}
1248```
1249
1250## Step 8: Main Integration
1251
1252**cmd/appview/main.go (additions):**
1253
1254```go
1255package main
1256
1257import (
1258 "log"
1259 "net/http"
1260 "time"
1261
1262 "github.com/gorilla/mux"
1263 "atcr.io/pkg/appview"
1264 "atcr.io/pkg/appview/handlers"
1265 "atcr.io/pkg/appview/db"
1266 "atcr.io/pkg/appview/session"
1267 "atcr.io/pkg/appview/middleware"
1268)
1269
1270func main() {
1271 // Initialize database
1272 database, err := db.InitDB("/var/lib/atcr/ui.db")
1273 if err != nil {
1274 log.Fatal(err)
1275 }
1276
1277 // Initialize session store
1278 sessionStore := session.NewStore()
1279
1280 // Start cleanup goroutine
1281 go func() {
1282 for {
1283 time.Sleep(5 * time.Minute)
1284 sessionStore.Cleanup()
1285 }
1286 }()
1287
1288 // Load embedded templates
1289 tmpl, err := appview.Templates()
1290 if err != nil {
1291 log.Fatal(err)
1292 }
1293
1294 // Setup router
1295 r := mux.NewRouter()
1296
1297 // Static files (embedded)
1298 r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", appview.StaticHandler()))
1299
1300 // UI routes (public)
1301 r.Handle("/ui/", &handlers.HomeHandler{
1302 DB: database,
1303 Templates: tmpl,
1304 })
1305
1306 // UI routes (authenticated)
1307 authRouter := r.PathPrefix("/ui").Subrouter()
1308 authRouter.Use(middleware.RequireAuth(sessionStore))
1309
1310 authRouter.Handle("/images", &handlers.ImagesHandler{
1311 DB: database,
1312 Templates: tmpl,
1313 })
1314
1315 authRouter.Handle("/settings", &handlers.SettingsHandler{
1316 Templates: tmpl,
1317 })
1318
1319 // API routes
1320 authRouter.HandleFunc("/api/images/{repository}/tags/{tag}",
1321 handlers.DeleteTag).Methods("DELETE")
1322 authRouter.HandleFunc("/api/images/{repository}/manifests/{digest}",
1323 handlers.DeleteManifest).Methods("DELETE")
1324
1325 // ... rest of your existing routes
1326
1327 log.Println("Server starting on :5000")
1328 http.ListenAndServe(":5000", r)
1329}
1330```
1331
1332## Step 9: Styling (Basic CSS)
1333
1334**pkg/appview/static/css/style.css:**
1335
1336```css
1337:root {
1338 --primary: #0066cc;
1339 --bg: #ffffff;
1340 --fg: #1a1a1a;
1341 --border: #e0e0e0;
1342 --code-bg: #f5f5f5;
1343}
1344
1345* {
1346 margin: 0;
1347 padding: 0;
1348 box-sizing: border-box;
1349}
1350
1351body {
1352 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1353 background: var(--bg);
1354 color: var(--fg);
1355 line-height: 1.6;
1356}
1357
1358.container {
1359 max-width: 1200px;
1360 margin: 0 auto;
1361 padding: 20px;
1362}
1363
1364/* Navigation */
1365.navbar {
1366 background: var(--fg);
1367 color: white;
1368 padding: 1rem 2rem;
1369 display: flex;
1370 justify-content: space-between;
1371 align-items: center;
1372}
1373
1374.nav-brand a {
1375 color: white;
1376 text-decoration: none;
1377 font-size: 1.5rem;
1378 font-weight: bold;
1379}
1380
1381.nav-links {
1382 display: flex;
1383 gap: 1rem;
1384 align-items: center;
1385}
1386
1387.nav-links a {
1388 color: white;
1389 text-decoration: none;
1390}
1391
1392/* Push Cards */
1393.push-card {
1394 border: 1px solid var(--border);
1395 border-radius: 8px;
1396 padding: 1rem;
1397 margin-bottom: 1rem;
1398 background: white;
1399}
1400
1401.push-header {
1402 font-size: 1.1rem;
1403 margin-bottom: 0.5rem;
1404}
1405
1406.push-user {
1407 color: var(--primary);
1408 text-decoration: none;
1409}
1410
1411.push-command {
1412 display: flex;
1413 gap: 0.5rem;
1414 align-items: center;
1415 margin-top: 0.5rem;
1416 padding: 0.5rem;
1417 background: var(--code-bg);
1418 border-radius: 4px;
1419}
1420
1421.pull-command {
1422 flex: 1;
1423 font-family: 'Monaco', 'Courier New', monospace;
1424 font-size: 0.9rem;
1425}
1426
1427.copy-btn {
1428 padding: 0.25rem 0.5rem;
1429 background: var(--primary);
1430 color: white;
1431 border: none;
1432 border-radius: 4px;
1433 cursor: pointer;
1434}
1435
1436/* Repository Cards */
1437.repository-card {
1438 border: 1px solid var(--border);
1439 border-radius: 8px;
1440 margin-bottom: 1rem;
1441 background: white;
1442}
1443
1444.repo-header {
1445 padding: 1rem;
1446 cursor: pointer;
1447 display: flex;
1448 justify-content: space-between;
1449 align-items: center;
1450 background: #f9f9f9;
1451 border-radius: 8px 8px 0 0;
1452}
1453
1454.repo-header:hover {
1455 background: #f0f0f0;
1456}
1457
1458.repo-details {
1459 padding: 1rem;
1460}
1461
1462.tag-row, .manifest-row {
1463 display: flex;
1464 gap: 1rem;
1465 align-items: center;
1466 padding: 0.5rem;
1467 border-bottom: 1px solid var(--border);
1468}
1469
1470.tag-row:last-child, .manifest-row:last-child {
1471 border-bottom: none;
1472}
1473
1474/* Modal */
1475.modal-overlay {
1476 position: fixed;
1477 top: 0;
1478 left: 0;
1479 right: 0;
1480 bottom: 0;
1481 background: rgba(0, 0, 0, 0.5);
1482 display: flex;
1483 justify-content: center;
1484 align-items: center;
1485 z-index: 1000;
1486}
1487
1488.modal-content {
1489 background: white;
1490 padding: 2rem;
1491 border-radius: 8px;
1492 max-width: 800px;
1493 max-height: 80vh;
1494 overflow-y: auto;
1495 position: relative;
1496}
1497
1498.modal-close {
1499 position: absolute;
1500 top: 1rem;
1501 right: 1rem;
1502 background: none;
1503 border: none;
1504 font-size: 1.5rem;
1505 cursor: pointer;
1506}
1507
1508.manifest-json {
1509 background: var(--code-bg);
1510 padding: 1rem;
1511 border-radius: 4px;
1512 overflow-x: auto;
1513 font-family: 'Monaco', 'Courier New', monospace;
1514 font-size: 0.85rem;
1515}
1516
1517/* Buttons */
1518button, .btn {
1519 padding: 0.5rem 1rem;
1520 background: var(--primary);
1521 color: white;
1522 border: none;
1523 border-radius: 4px;
1524 cursor: pointer;
1525 text-decoration: none;
1526 display: inline-block;
1527}
1528
1529button:hover, .btn:hover {
1530 opacity: 0.9;
1531}
1532
1533.delete-btn {
1534 background: #dc3545;
1535}
1536
1537/* Loading state */
1538.loading {
1539 text-align: center;
1540 padding: 2rem;
1541 color: #666;
1542}
1543
1544/* Forms */
1545.form-group {
1546 margin-bottom: 1rem;
1547}
1548
1549.form-group label {
1550 display: block;
1551 margin-bottom: 0.5rem;
1552 font-weight: 500;
1553}
1554
1555.form-group input,
1556.form-group select {
1557 width: 100%;
1558 padding: 0.5rem;
1559 border: 1px solid var(--border);
1560 border-radius: 4px;
1561 font-size: 1rem;
1562}
1563```
1564
1565## Step 10: Helper Functions
1566
1567**pkg/appview/static/js/app.js:**
1568
1569```javascript
1570// Copy to clipboard
1571function copyToClipboard(text) {
1572 navigator.clipboard.writeText(text).then(() => {
1573 // Show success feedback
1574 const btn = event.target;
1575 const originalText = btn.textContent;
1576 btn.textContent = '✓ Copied!';
1577 setTimeout(() => {
1578 btn.textContent = originalText;
1579 }, 2000);
1580 });
1581}
1582
1583// Time ago helper (for client-side rendering)
1584function timeAgo(date) {
1585 const seconds = Math.floor((new Date() - new Date(date)) / 1000);
1586
1587 const intervals = {
1588 year: 31536000,
1589 month: 2592000,
1590 week: 604800,
1591 day: 86400,
1592 hour: 3600,
1593 minute: 60,
1594 second: 1
1595 };
1596
1597 for (const [name, secondsInInterval] of Object.entries(intervals)) {
1598 const interval = Math.floor(seconds / secondsInInterval);
1599 if (interval >= 1) {
1600 return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`;
1601 }
1602 }
1603
1604 return 'just now';
1605}
1606
1607// Update timestamps on page load
1608document.addEventListener('DOMContentLoaded', () => {
1609 document.querySelectorAll('time[datetime]').forEach(el => {
1610 const date = el.getAttribute('datetime');
1611 el.textContent = timeAgo(date);
1612 });
1613});
1614```
1615
1616**Template helper functions (in Go):**
1617
1618```go
1619// Add to your template loading
1620funcMap := template.FuncMap{
1621 "timeAgo": func(t time.Time) string {
1622 duration := time.Since(t)
1623
1624 if duration < time.Minute {
1625 return "just now"
1626 } else if duration < time.Hour {
1627 mins := int(duration.Minutes())
1628 if mins == 1 {
1629 return "1 minute ago"
1630 }
1631 return fmt.Sprintf("%d minutes ago", mins)
1632 } else if duration < 24*time.Hour {
1633 hours := int(duration.Hours())
1634 if hours == 1 {
1635 return "1 hour ago"
1636 }
1637 return fmt.Sprintf("%d hours ago", hours)
1638 } else {
1639 days := int(duration.Hours() / 24)
1640 if days == 1 {
1641 return "1 day ago"
1642 }
1643 return fmt.Sprintf("%d days ago", days)
1644 }
1645 },
1646
1647 "humanizeBytes": func(bytes int64) string {
1648 const unit = 1024
1649 if bytes < unit {
1650 return fmt.Sprintf("%d B", bytes)
1651 }
1652 div, exp := int64(unit), 0
1653 for n := bytes / unit; n >= unit; n /= unit {
1654 div *= unit
1655 exp++
1656 }
1657 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
1658 },
1659}
1660
1661tmpl := template.New("").Funcs(funcMap)
1662tmpl = template.Must(tmpl.ParseGlob("web/templates/**/*.html"))
1663```
1664
1665## Implementation Checklist
1666
1667### Phase 1: Foundation
1668- [ ] Set up project structure
1669- [ ] Initialize SQLite database with schema
1670- [ ] Create data models and query functions
1671- [ ] Write database tests
1672
1673### Phase 2: Templates
1674- [ ] Create base layout template
1675- [ ] Create navigation component
1676- [ ] Create home page template
1677- [ ] Create settings page template
1678- [ ] Create images page template
1679- [ ] Create modal templates
1680
1681### Phase 3: Handlers
1682- [ ] Implement home handler (firehose display)
1683- [ ] Implement settings handler (profile + holds)
1684- [ ] Implement images handler (repository list)
1685- [ ] Implement API endpoints (delete tag, delete manifest)
1686- [ ] Add HTMX partial responses
1687
1688### Phase 4: Authentication
1689- [ ] Implement session store
1690- [ ] Create auth middleware
1691- [ ] Wire up OAuth login (reuse existing)
1692- [ ] Add logout functionality
1693- [ ] Test auth flow
1694
1695### Phase 5: Firehose Worker
1696- [ ] Implement Jetstream client
1697- [ ] Create firehose worker
1698- [ ] Add event handlers (manifest, tag)
1699- [ ] Test with real firehose
1700- [ ] Add cursor persistence
1701
1702### Phase 6: Polish
1703- [ ] Add CSS styling
1704- [ ] Implement copy-to-clipboard
1705- [ ] Add loading states
1706- [ ] Error handling and user feedback
1707- [ ] Responsive design
1708- [ ] CSRF protection
1709
1710### Phase 7: Testing
1711- [ ] Unit tests for handlers
1712- [ ] Database query tests
1713- [ ] Integration tests (full flow)
1714- [ ] Manual testing with real data
1715
1716## Performance Optimizations
1717
1718### HTMX Optimizations
17191. **Prefetching:** Add `hx-trigger="mouseenter"` to links for hover prefetch
17202. **Caching:** Use `hx-cache="true"` for cacheable content
17213. **Optimistic updates:** Remove elements immediately, rollback on error
17224. **Debouncing:** Add `delay:500ms` to search inputs
1723
1724### Database Optimizations
17251. **Indexes:** Already defined in schema (did, repo, created_at, digest)
17262. **Connection pooling:** Use `db.SetMaxOpenConns(25)`
17273. **Prepared statements:** Cache frequently used queries
17284. **Batch inserts:** For firehose events, batch into transactions
1729
1730### Template Optimizations
17311. **Pre-parse:** Parse templates once at startup, not per request
17322. **Caching:** Cache rendered partials for static content
17333. **Minification:** Minify HTML/CSS/JS in production
1734
1735## Security Checklist
1736
1737- [ ] Session cookies: Secure, HttpOnly, SameSite=Lax
1738- [ ] CSRF tokens for mutations (POST/DELETE)
1739- [ ] Input validation (sanitize search, filters)
1740- [ ] Rate limiting on API endpoints
1741- [ ] SQL injection protection (parameterized queries)
1742- [ ] Authorization checks (user owns resource)
1743- [ ] XSS protection (escape template output)
1744
1745## Deployment
1746
1747### Development
1748```bash
1749# Run migrations
1750go run cmd/appview/main.go migrate
1751
1752# Start server
1753go run cmd/appview/main.go serve
1754```
1755
1756### Production
1757```bash
1758# Build binary
1759go build -o bin/atcr-appview ./cmd/appview
1760
1761# Run with config
1762./bin/atcr-appview serve config/production.yml
1763```
1764
1765### Environment Variables
1766```bash
1767UI_ENABLED=true
1768UI_DATABASE_PATH=/var/lib/atcr/ui.db
1769UI_FIREHOSE_ENDPOINT=wss://jetstream.atproto.tools/subscribe
1770UI_SESSION_DURATION=24h
1771```
1772
1773## Next Steps After V1
1774
17751. **Add search:** Implement full-text search on SQLite
17762. **Public profiles:** `/ui/@alice` shows public view
17773. **Manifest diff:** Compare manifest versions
17784. **Export data:** Download all your images as JSON
17795. **Webhook notifications:** Alert on new pushes
17806. **CLI integration:** `atcr ui open` to launch browser
1781
1782---
1783
1784## Key Benefits of This Approach
1785
1786### Single Binary Deployment
1787- All templates and static files embedded with `//go:embed`
1788- No need to ship separate `web/` directory
1789- Single `atcr-appview` binary contains everything
1790- Easy deployment: just copy one file
1791
1792### Package Structure
1793- `pkg/appview` makes sense semantically (it's the AppView, not just UI)
1794- Contains both backend (db, firehose) and frontend (templates, handlers)
1795- Clear separation from core OCI registry logic
1796- Easy to test and develop independently
1797
1798### Embedded Assets
1799```go
1800// pkg/appview/appview.go
1801//go:embed templates/*.html templates/**/*.html
1802var templatesFS embed.FS
1803
1804//go:embed static/*
1805var staticFS embed.FS
1806```
1807
1808**Build:**
1809```bash
1810go build -o bin/atcr-appview ./cmd/appview
1811```
1812
1813**Deploy:**
1814```bash
1815scp bin/atcr-appview server:/usr/local/bin/
1816# Done! No webpack, no node_modules, no separate assets folder
1817```
1818
1819### Development Workflow
18201. Edit templates in `pkg/appview/templates/`
18212. Edit CSS/JS in `pkg/appview/static/`
18223. Run `go build` - assets auto-embedded
18234. No build tools, no npm, just Go
1824
1825---
1826
1827This 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.