1828 lines
47 KiB
Markdown
1828 lines
47 KiB
Markdown
# 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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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:**
|
|
|
|
```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):**
|
|
|
|
```go
|
|
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:**
|
|
|
|
```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:**
|
|
|
|
```javascript
|
|
// 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):**
|
|
|
|
```go
|
|
// Add to your template loading
|
|
funcMap := template.FuncMap{
|
|
"timeAgo": func(t time.Time) string {
|
|
duration := time.Since(t)
|
|
|
|
if duration < time.Minute {
|
|
return "just now"
|
|
} else if duration < time.Hour {
|
|
mins := int(duration.Minutes())
|
|
if mins == 1 {
|
|
return "1 minute ago"
|
|
}
|
|
return fmt.Sprintf("%d minutes ago", mins)
|
|
} else if duration < 24*time.Hour {
|
|
hours := int(duration.Hours())
|
|
if hours == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", hours)
|
|
} else {
|
|
days := int(duration.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
}
|
|
},
|
|
|
|
"humanizeBytes": func(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
},
|
|
}
|
|
|
|
tmpl := template.New("").Funcs(funcMap)
|
|
tmpl = template.Must(tmpl.ParseGlob("web/templates/**/*.html"))
|
|
```
|
|
|
|
## Implementation Checklist
|
|
|
|
### Phase 1: Foundation
|
|
- [ ] Set up project structure
|
|
- [ ] Initialize SQLite database with schema
|
|
- [ ] Create data models and query functions
|
|
- [ ] Write database tests
|
|
|
|
### Phase 2: Templates
|
|
- [ ] Create base layout template
|
|
- [ ] Create navigation component
|
|
- [ ] Create home page template
|
|
- [ ] Create settings page template
|
|
- [ ] Create images page template
|
|
- [ ] Create modal templates
|
|
|
|
### Phase 3: Handlers
|
|
- [ ] Implement home handler (firehose display)
|
|
- [ ] Implement settings handler (profile + holds)
|
|
- [ ] Implement images handler (repository list)
|
|
- [ ] Implement API endpoints (delete tag, delete manifest)
|
|
- [ ] Add HTMX partial responses
|
|
|
|
### Phase 4: Authentication
|
|
- [ ] Implement session store
|
|
- [ ] Create auth middleware
|
|
- [ ] Wire up OAuth login (reuse existing)
|
|
- [ ] Add logout functionality
|
|
- [ ] Test auth flow
|
|
|
|
### Phase 5: Firehose Worker
|
|
- [ ] Implement Jetstream client
|
|
- [ ] Create firehose worker
|
|
- [ ] Add event handlers (manifest, tag)
|
|
- [ ] Test with real firehose
|
|
- [ ] Add cursor persistence
|
|
|
|
### Phase 6: Polish
|
|
- [ ] Add CSS styling
|
|
- [ ] Implement copy-to-clipboard
|
|
- [ ] Add loading states
|
|
- [ ] Error handling and user feedback
|
|
- [ ] Responsive design
|
|
- [ ] CSRF protection
|
|
|
|
### Phase 7: Testing
|
|
- [ ] Unit tests for handlers
|
|
- [ ] Database query tests
|
|
- [ ] Integration tests (full flow)
|
|
- [ ] Manual testing with real data
|
|
|
|
## Performance Optimizations
|
|
|
|
### HTMX Optimizations
|
|
1. **Prefetching:** Add `hx-trigger="mouseenter"` to links for hover prefetch
|
|
2. **Caching:** Use `hx-cache="true"` for cacheable content
|
|
3. **Optimistic updates:** Remove elements immediately, rollback on error
|
|
4. **Debouncing:** Add `delay:500ms` to search inputs
|
|
|
|
### Database Optimizations
|
|
1. **Indexes:** Already defined in schema (did, repo, created_at, digest)
|
|
2. **Connection pooling:** Use `db.SetMaxOpenConns(25)`
|
|
3. **Prepared statements:** Cache frequently used queries
|
|
4. **Batch inserts:** For firehose events, batch into transactions
|
|
|
|
### Template Optimizations
|
|
1. **Pre-parse:** Parse templates once at startup, not per request
|
|
2. **Caching:** Cache rendered partials for static content
|
|
3. **Minification:** Minify HTML/CSS/JS in production
|
|
|
|
## Security Checklist
|
|
|
|
- [ ] Session cookies: Secure, HttpOnly, SameSite=Lax
|
|
- [ ] CSRF tokens for mutations (POST/DELETE)
|
|
- [ ] Input validation (sanitize search, filters)
|
|
- [ ] Rate limiting on API endpoints
|
|
- [ ] SQL injection protection (parameterized queries)
|
|
- [ ] Authorization checks (user owns resource)
|
|
- [ ] XSS protection (escape template output)
|
|
|
|
## Deployment
|
|
|
|
### Development
|
|
```bash
|
|
# Run migrations
|
|
go run cmd/appview/main.go migrate
|
|
|
|
# Start server
|
|
go run cmd/appview/main.go serve
|
|
```
|
|
|
|
### Production
|
|
```bash
|
|
# Build binary
|
|
go build -o bin/atcr-appview ./cmd/appview
|
|
|
|
# Run with config
|
|
./bin/atcr-appview serve config/production.yml
|
|
```
|
|
|
|
### Environment Variables
|
|
```bash
|
|
UI_ENABLED=true
|
|
UI_DATABASE_PATH=/var/lib/atcr/ui.db
|
|
UI_FIREHOSE_ENDPOINT=wss://jetstream.atproto.tools/subscribe
|
|
UI_SESSION_DURATION=24h
|
|
```
|
|
|
|
## Next Steps After V1
|
|
|
|
1. **Add search:** Implement full-text search on SQLite
|
|
2. **Public profiles:** `/ui/@alice` shows public view
|
|
3. **Manifest diff:** Compare manifest versions
|
|
4. **Export data:** Download all your images as JSON
|
|
5. **Webhook notifications:** Alert on new pushes
|
|
6. **CLI integration:** `atcr ui open` to launch browser
|
|
|
|
---
|
|
|
|
## Key Benefits of This Approach
|
|
|
|
### Single Binary Deployment
|
|
- All templates and static files embedded with `//go:embed`
|
|
- No need to ship separate `web/` directory
|
|
- Single `atcr-appview` binary contains everything
|
|
- Easy deployment: just copy one file
|
|
|
|
### Package Structure
|
|
- `pkg/appview` makes sense semantically (it's the AppView, not just UI)
|
|
- Contains both backend (db, firehose) and frontend (templates, handlers)
|
|
- Clear separation from core OCI registry logic
|
|
- Easy to test and develop independently
|
|
|
|
### Embedded Assets
|
|
```go
|
|
// pkg/appview/appview.go
|
|
//go:embed templates/*.html templates/**/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
```
|
|
|
|
**Build:**
|
|
```bash
|
|
go build -o bin/atcr-appview ./cmd/appview
|
|
```
|
|
|
|
**Deploy:**
|
|
```bash
|
|
scp bin/atcr-appview server:/usr/local/bin/
|
|
# Done! No webpack, no node_modules, no separate assets folder
|
|
```
|
|
|
|
### Development Workflow
|
|
1. Edit templates in `pkg/appview/templates/`
|
|
2. Edit CSS/JS in `pkg/appview/static/`
|
|
3. Run `go build` - assets auto-embedded
|
|
4. No build tools, no npm, just Go
|
|
|
|
---
|
|
|
|
This guide provides a complete implementation path for ATCR AppView UI using html/template + HTMX with embedded assets. Start with Phase 1 (embed setup + database) and work your way through each phase sequentially.
|