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.

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.