begin large refactor of UI to use tailwind and daisy

This commit is contained in:
Evan Jarrett
2026-01-14 14:42:04 -06:00
parent b1767cfb6b
commit 4c0f20a32e
68 changed files with 4005 additions and 4467 deletions
+3 -3
View File
@@ -5,9 +5,9 @@ tmp_dir = "tmp"
cmd = "go build -buildvcs=false -o ./tmp/atcr-hold ./cmd/hold"
entrypoint = ["./tmp/atcr-hold"]
include_ext = ["go"]
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview"]
exclude_regex = ["_test\\.go$"]
delay = 1000
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview", "node_modules"]
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$"]
delay = 3000
stop_on_error = true
send_interrupt = true
kill_delay = 500
+5 -5
View File
@@ -3,16 +3,16 @@ tmp_dir = "tmp"
[build]
# Pre-build: generate assets if missing (each string is a shell command)
pre_cmd = ["[ -f pkg/appview/static/js/htmx.min.js ] || go generate ./..."]
pre_cmd = ["go generate ./..."]
cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
entrypoint = ["./tmp/atcr-appview", "serve"]
include_ext = ["go", "html", "css", "js"]
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist"]
exclude_regex = ["_test\\.go$"]
delay = 1000
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules"]
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$"]
delay = 3000
stop_on_error = true
send_interrupt = true
kill_delay = 500
kill_delay = 2000
[log]
time = false
+2
View File
@@ -0,0 +1,2 @@
# Generated files
pkg/appview/public/css/style.css
+3 -2
View File
@@ -17,8 +17,8 @@ quotas.yaml
# Generated assets (run go generate to rebuild)
pkg/appview/licenses/spdx-licenses.json
pkg/appview/static/js/htmx.min.js
pkg/appview/static/js/lucide.min.js
pkg/appview/public/js/htmx.min.js
pkg/appview/public/js/lucide.min.js
# IDE
.zed/
@@ -31,3 +31,4 @@ pkg/appview/static/js/lucide.min.js
# OS
.DS_Store
Thumbs.db
node_modules
+2 -2
View File
@@ -455,9 +455,9 @@ The AppView includes a web interface for browsing the registry:
- `settings.go` - User settings management
- `api.go` - JSON API endpoints
**Static Assets** (`pkg/appview/static/`, `pkg/appview/templates/`):
**Static Assets** (`pkg/appview/public/`, `pkg/appview/templates/`):
- Templates use Go html/template
- JavaScript in `static/js/app.js`
- JavaScript in `public/js/app.js`
- Minimal CSS for clean UI
#### Hold Service (`cmd/hold/`)
+1 -1
View File
@@ -9,7 +9,7 @@ ENV DEBIAN_FRONTEND=noninteractive
ENV AIR_CONFIG=${AIR_CONFIG}
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl nodejs npm && \
rm -rf /var/lib/apt/lists/* && \
go install github.com/air-verse/air@latest
+4 -4
View File
@@ -16,8 +16,8 @@ all: generate build ## Generate assets and build all binaries (default)
# Generated asset files
GENERATED_ASSETS = \
pkg/appview/static/js/htmx.min.js \
pkg/appview/static/js/lucide.min.js \
pkg/appview/public/js/htmx.min.js \
pkg/appview/public/js/lucide.min.js \
pkg/appview/licenses/spdx-licenses.json
generate: $(GENERATED_ASSETS) ## Run go generate to download vendor assets
@@ -113,7 +113,7 @@ develop-down: ## Stop docker-compose services
clean: ## Remove built binaries and generated assets
@echo "→ Cleaning build artifacts..."
rm -rf bin/
rm -f pkg/appview/static/js/htmx.min.js
rm -f pkg/appview/static/js/lucide.min.js
rm -f pkg/appview/public/js/htmx.min.js
rm -f pkg/appview/public/js/lucide.min.js
rm -f pkg/appview/licenses/spdx-licenses.json
@echo "✓ Clean complete"
+1 -1
View File
@@ -131,7 +131,7 @@ pkg/
│ ├── jetstream/ # ATProto Jetstream consumer
│ ├── middleware/ # Auth & registry middleware
│ ├── storage/ # Storage routing (hold cache, blob proxy, repository)
│ ├── static/ # Static assets (JS, CSS, install scripts)
│ ├── public/ # Static assets (JS, CSS, install scripts)
│ └── templates/ # HTML templates
├── atproto/ # ATProto client, records, manifest/tag stores
├── auth/
Executable
BIN
View File
Binary file not shown.
+6 -6
View File
@@ -347,8 +347,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Mount static files if UI is enabled
if uiSessionStore != nil && uiTemplates != nil {
// Register dynamic routes for root-level files (favicons, manifests, etc.)
staticHandler := appview.StaticHandler()
rootFiles, err := appview.StaticRootFiles()
publicHandler := appview.PublicHandler()
rootFiles, err := appview.PublicRootFiles()
if err != nil {
slog.Warn("Failed to scan static root files", "error", err)
} else {
@@ -358,16 +358,16 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
mainRouter.Get("/"+file, func(w http.ResponseWriter, r *http.Request) {
// Serve the specific file from static root
r.URL.Path = "/" + file
staticHandler.ServeHTTP(w, r)
publicHandler.ServeHTTP(w, r)
})
}
slog.Info("Registered dynamic root file routes", "count", len(rootFiles), "files", rootFiles)
}
// Mount subdirectory routes with clean paths
mainRouter.Handle("/css/*", http.StripPrefix("/css/", appview.StaticSubdir("css")))
mainRouter.Handle("/js/*", http.StripPrefix("/js/", appview.StaticSubdir("js")))
mainRouter.Handle("/static/*", http.StripPrefix("/static/", appview.StaticSubdir("static")))
mainRouter.Handle("/css/*", http.StripPrefix("/css/", appview.PublicSubdir("css")))
mainRouter.Handle("/js/*", http.StripPrefix("/js/", appview.PublicSubdir("js")))
mainRouter.Handle("/static/*", http.StripPrefix("/static/", appview.PublicSubdir("static")))
slog.Info("UI enabled", "home", "/", "settings", "/settings")
}
+4 -4
View File
@@ -135,7 +135,7 @@ pkg/hold/admin/
│ ├── crew_row.html # Single crew row (for HTMX updates)
│ ├── usage_stats.html # Usage stats partial
│ └── top_users.html # Top users table partial
└── static/
└── public/
├── css/
│ └── admin.css # Admin-specific styles
└── js/
@@ -406,7 +406,7 @@ This keeps all hold data together while maintaining separation between the carst
| `/admin/auth/oauth/authorize` | GET | Public | OAuth authorize | Start OAuth flow |
| `/admin/auth/oauth/callback` | GET | Public | `CallbackHandler` | OAuth callback |
| `/admin/auth/logout` | GET | Owner | `LogoutHandler` | Logout and clear session |
| `/admin/static/*` | GET | Public | Static files | CSS, JS assets |
| `/admin/public/*` | GET | Public | Static files | CSS, JS assets |
### Route Registration
@@ -418,7 +418,7 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) {
r.Get("/admin/auth/oauth/callback", ui.handleCallback)
// Static files (public)
r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", ui.staticHandler()))
r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", ui.staticHandler()))
// Protected routes (require owner)
r.Group(func(r chi.Router) {
@@ -899,7 +899,7 @@ func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
```html
{{ define "head" }}
<link rel="stylesheet" href="/admin/static/css/admin.css">
<link rel="stylesheet" href="/admin/public/css/admin.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/lucide@latest"></script>
{{ end }}
+9 -9
View File
@@ -65,7 +65,7 @@
│ │ ui.go checks DEV_MODE: │ │
│ │ if DEV_MODE: │ │
│ │ templatesFS = os.DirFS("...") │ │
│ │ staticFS = os.DirFS("...") │ │
│ │ publicFS = os.DirFS("...") │ │
│ │ else: │ │
│ │ use embed.FS (production) │ │
│ │ │ │
@@ -78,7 +78,7 @@
#### Scenario 1: Edit CSS/JS/Templates
```
1. Edit pkg/appview/static/css/style.css in VSCode
1. Edit pkg/appview/public/css/style.css in VSCode
2. Save file
3. Change appears in container via volume mount (instant)
4. App uses os.DirFS → reads new file from disk (instant)
@@ -313,23 +313,23 @@ import (
var embeddedTemplatesFS embed.FS
//go:embed static
var embeddedStaticFS embed.FS
var embeddedpublicFS embed.FS
// Actual filesystems used at runtime (conditional)
var templatesFS fs.FS
var staticFS fs.FS
var publicFS fs.FS
func init() {
// Development mode: read from filesystem for instant updates
if os.Getenv("ATCR_DEV_MODE") == "true" {
log.Println("🔧 DEV MODE: Using filesystem for templates and static assets")
templatesFS = os.DirFS("pkg/appview/templates")
staticFS = os.DirFS("pkg/appview/static")
publicFS = os.DirFS("pkg/appview/static")
} else {
// Production mode: use embedded assets
log.Println("📦 PRODUCTION MODE: Using embedded assets")
templatesFS = embeddedTemplatesFS
staticFS = embeddedStaticFS
publicFS = embeddedpublicFS
}
}
@@ -344,7 +344,7 @@ func Templates() *template.Template {
// StaticHandler returns a handler for static files
func StaticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
sub, err := fs.Sub(publicFS, "static")
if err != nil {
log.Fatalf("Failed to create static sub-filesystem: %v", err)
}
@@ -442,8 +442,8 @@ atcr-appview | running...
```bash
# Edit any template, CSS, or JS file
vim pkg/appview/templates/pages/home.html
vim pkg/appview/static/css/style.css
vim pkg/appview/static/js/app.js
vim pkg/appview/public/css/style.css
vim pkg/appview/public/js/app.js
# Save file → changes appear instantly
# Just refresh browser (Cmd+R / Ctrl+R)
+27 -27
View File
@@ -4,7 +4,7 @@
ATCR embeds static assets (CSS, JavaScript) directly into the binary using Go's `embed` directive. Currently:
- **CSS Size:** 40KB (`pkg/appview/static/css/style.css`, 2,210 lines)
- **CSS Size:** 40KB (`pkg/appview/public/css/style.css`, 2,210 lines)
- **Embedded:** All static files compiled into binary at build time
- **No Minification:** Source files embedded as-is
@@ -37,7 +37,7 @@ require github.com/tdewolff/minify/v2 v2.20.37
### Step 2: Create Minification Script
Create `pkg/appview/static/minify_assets.go`:
Create `pkg/appview/public/minify_assets.go`:
```go
//go:build ignore
@@ -68,16 +68,16 @@ func main() {
// Minify CSS
if err := minifyFile(m, "text/css",
filepath.Join(dir, "pkg/appview/static/css/style.css"),
filepath.Join(dir, "pkg/appview/static/css/style.min.css"),
filepath.Join(dir, "pkg/appview/public/css/style.css"),
filepath.Join(dir, "pkg/appview/public/css/style.min.css"),
); err != nil {
log.Fatalf("Failed to minify CSS: %v", err)
}
// Minify JavaScript
if err := minifyFile(m, "text/javascript",
filepath.Join(dir, "pkg/appview/static/js/app.js"),
filepath.Join(dir, "pkg/appview/static/js/app.min.js"),
filepath.Join(dir, "pkg/appview/public/js/app.js"),
filepath.Join(dir, "pkg/appview/public/js/app.min.js"),
); err != nil {
log.Fatalf("Failed to minify JS: %v", err)
}
@@ -120,10 +120,10 @@ func minifyFile(m *minify.M, mediatype, src, dst string) error {
Add to `pkg/appview/ui.go` (before the `//go:embed` directive):
```go
//go:generate go run ./static/minify_assets.go
//go:generate go run ./public/minify_assets.go
//go:embed static
var staticFS embed.FS
//go:embed public
var publicFS embed.FS
```
### Step 4: Update HTML Templates
@@ -132,14 +132,14 @@ Update all template files to reference minified assets:
**Before:**
```html
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/app.js"></script>
<link rel="stylesheet" href="/public/css/style.css">
<script src="/public/js/app.js"></script>
```
**After:**
```html
<link rel="stylesheet" href="/static/css/style.min.css">
<script src="/static/js/app.min.js"></script>
<link rel="stylesheet" href="/public/css/style.min.css">
<script src="/public/js/app.min.js"></script>
```
**Files to update:**
@@ -167,8 +167,8 @@ Add minified files to `.gitignore` since they're generated:
```
# Generated minified assets
pkg/appview/static/css/*.min.css
pkg/appview/static/js/*.min.js
pkg/appview/public/css/*.min.css
pkg/appview/public/js/*.min.js
```
**Alternative:** Commit minified files if you want reproducible builds without running `go generate`.
@@ -194,23 +194,23 @@ Use build tags to serve unminified assets in development:
//go:build !production
//go:embed static
var staticFS embed.FS
var publicFS embed.FS
func StylePath() string { return "/static/css/style.css" }
func ScriptPath() string { return "/static/js/app.js" }
func StylePath() string { return "/public/css/style.css" }
func ScriptPath() string { return "/public/js/app.js" }
```
**pkg/appview/ui_production.go** (production):
```go
//go:build production
//go:generate go run ./static/minify_assets.go
//go:generate go run ./public/minify_assets.go
//go:embed static
var staticFS embed.FS
var publicFS embed.FS
func StylePath() string { return "/static/css/style.min.css" }
func ScriptPath() string { return "/static/js/app.min.js" }
func StylePath() string { return "/public/css/style.min.css" }
func ScriptPath() string { return "/public/js/app.min.js" }
```
**Usage:**
@@ -230,8 +230,8 @@ go build -tags production ./cmd/appview
Use Node.js-based minifiers via `go:generate`:
```go
//go:generate sh -c "npx cssnano static/css/style.css static/css/style.min.css"
//go:generate sh -c "npx esbuild static/js/app.js --minify --outfile=static/js/app.min.js"
//go:generate sh -c "npx cssnano public/css/style.css public/css/style.min.css"
//go:generate sh -c "npx esbuild public/js/app.js --minify --outfile=public/js/app.min.js"
```
**Pros:**
@@ -251,7 +251,7 @@ Compress assets at runtime (complementary to minification):
import "github.com/NYTimes/gziphandler"
// Wrap static handler
mux.Handle("/static/", gziphandler.GzipHandler(appview.StaticHandler()))
mux.Handle("/public/", gziphandler.GzipHandler(appview.StaticHandler()))
```
**Pros:**
@@ -316,8 +316,8 @@ func BrotliHandler(h http.Handler) http.Handler {
### Development Workflow
1. **Edit source files:**
- Modify `pkg/appview/static/css/style.css`
- Modify `pkg/appview/static/js/app.js`
- Modify `pkg/appview/public/css/style.css`
- Modify `pkg/appview/public/js/app.js`
2. **Test locally:**
```bash
+558
View File
@@ -0,0 +1,558 @@
# Website Visual Improvement Plan
## Goal
Create a fun, personality-driven container registry that embraces its nautical theme while being clearly functional. Think GitHub's Octocat or DigitalOcean's Sammy - playful but professional.
## Brand Identity (from seahorse logo)
- **Primary Teal**: #4ECDC4 (body color) - the "ocean" feel
- **Dark Teal**: #2E8B8B (mane/fins) - depth and contrast
- **Mint Background**: #C8F0E7 - light, airy, underwater
- **Coral Accent**: #FF6B6B (eye) - warmth, CTAs, highlights
- **Nautical theme to embrace:**
- "Ship" containers (not just push)
- "Holds" for storage (like a ship's cargo hold)
- "Sailors" are users, "Captains" own holds
- Seahorse mascot as the friendly guide
## Design Direction: Fun but Functional
- Softer, more rounded corners
- Playful color combinations (teal + coral)
- Mascot appearances in empty states, loading, errors
- Ocean-inspired subtle backgrounds (gradients, waves)
- Friendly copy and microcopy throughout
- Still clearly a container registry with all the technical info
## Current State
- Pure CSS with custom properties for theming
- Basic card designs for repositories
- Simple hero section with terminal mockup
- Existing badges: Helm charts, multi-arch, attestations
- Existing stats: stars, pull counts
## Layout Wireframes
### Current Homepage Layout
```
┌─────────────────────────────────────────────────────────────────┐
│ [Logo] [Search] [Theme] [User] │ Navbar
├─────────────────────────────────────────────────────────────────┤
│ │
│ ship containers on the open web. │ Hero
│ ┌─────────────────────────┐ │
│ │ $ docker login atcr.io │ │
│ └─────────────────────────┘ │
│ [Get Started] [Learn More] │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Benefits
│ │ Docker │ │ Your Data │ │ Discover │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Featured │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ [icon] user/repo ★ 12 ↓ 340 ││ WIDE cards
│ │ Description text here... ││ (current)
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ [icon] user/repo2 ★ 5 ↓ 120 ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ What's New │
│ (similar wide cards) │
└─────────────────────────────────────────────────────────────────┘
```
### Proposed Layout: Tile Grid
```
┌─────────────────────────────────────────────────────────────────┐
│ [Logo] [Search] [Theme] [User] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ship containers on the open web. │
│ ┌─────────────────────────┐ │
│ │ $ docker login atcr.io │ │
│ └─────────────────────────┘ │
│ [Get Started] [Learn More] │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Docker │ │ Your Data │ │ Discover │ │
│ └────────────┘ └────────────┘ └────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Featured [View All] │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐│
│ │ [icon] │ │ [icon] │ │ [icon] ││ 3 columns
│ │ user/repo │ │ user/repo2 │ │ user/repo3 ││ ~300px each
│ │ Description... │ │ Description... │ │ Description... ││
│ │ ────────────────││ │ ────────────────││ │ ────────────────│││
│ │ ★ 12 ↓ 340 │ │ ★ 5 ↓ 120 │ │ ★ 8 ↓ 89 ││
│ └──────────────────┘ └──────────────────┘ └──────────────────┘│
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐│
│ │ ... │ │ ... │ │ ... ││
│ └──────────────────┘ └──────────────────┘ └──────────────────┘│
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ What's New │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐│
│ │ ... │ │ ... │ │ ... ││ Same tile
│ └──────────────────┘ └──────────────────┘ └──────────────────┘│ layout
└─────────────────────────────────────────────────────────────────┘
```
### Unified Tile Card (Same for Featured & What's New)
```
┌─────────────────────────────┐
│ ┌────┐ user/repo [Helm] │ Icon + name + type badge
│ │icon│ :latest │ Tag (if applicable)
│ └────┘ │
│ │
│ Description text that │ Description (2-3 lines max)
│ wraps nicely here... │
│ │
│ sha256:abcdef12 │ Digest (truncated)
│ ───────────────────────────│ Divider
│ ★ 12 ↓ 340 1 day ago │ Stats + timestamp
└─────────────────────────────┘
Card anatomy:
┌─────────────────────────────┐
│ HEADER │ - Icon (48x48)
│ - icon + name + badge │ - user/repo
│ - tag (optional) │ - :tag or :latest
├─────────────────────────────┤
│ BODY │ - Description (clamp 2-3 lines)
│ - description │ - sha256:abc... (monospace)
│ - digest │
├─────────────────────────────┤
│ FOOTER │ - ★ star count
│ - stats + time │ - ↓ pull count
│ │ - "2 hours ago"
└─────────────────────────────┘
```
### Both Sections Use Same Card (Different Sort)
```
Featured (by stars/curated): What's New (by last_push):
┌─────────────────────────┐ ┌─────────────────────────┐
│ user/repo │ │ user/repo │
│ :latest │ │ :v1.2.3 │ ← latest tag
│ Description... │ │ Description... │
│ │ │ │
│ sha256:abc123 │ │ sha256:def456 │ ← latest digest
│ ───────────────────────│ │ ───────────────────────│
│ ★ 12 ↓ 340 1 day ago │ │ ★ 5 ↓ 89 2 hrs ago │ ← last_push time
└─────────────────────────┘ └─────────────────────────┘
Same card component, different data source:
- Featured: GetFeaturedRepos() (curated or by stars)
- What's New: GetRecentlyUpdatedRepos() (ORDER BY last_push DESC)
```
### Card Dimensions Comparison
```
Current: █████████████████████████████████████████ (~800px+ wide)
Proposed: ████████████ ████████████ ████████████ (~280-320px each)
Card 1 Card 2 Card 3
```
### Mobile Responsive Behavior
```
Desktop (>1024px): [Card] [Card] [Card] 3 columns
Tablet (768-1024px): [Card] [Card] 2 columns
Mobile (<768px): [Card] 1 column (full width)
```
### Playful Elements
```
Empty State (no repos):
┌─────────────────────────────────────────┐
│ │
│ 🐴 (seahorse) │
│ "Nothing here yet!" │
│ │
│ Ship your first container to get │
│ started on your voyage. │
│ │
│ [Start Shipping] │
└─────────────────────────────────────────┘
Error/404:
┌─────────────────────────────────────────┐
│ │
│ 🐴 (confused seahorse) │
│ "Lost at sea!" │
│ │
│ We couldn't find that container. │
│ Maybe it drifted away? │
│ │
│ [Back to Shore] │
└─────────────────────────────────────────┘
Hero with subtle ocean feel:
┌─────────────────────────────────────────┐
│ ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋ │ Subtle wave pattern bg
│ │
│ ship containers on the │
│ open web. 🐴 │ Mascot appears!
│ │
│ ┌─────────────────────┐ │
│ │ $ docker login ... │ │
│ └─────────────────────┘ │
│ │
│ ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋ │
└─────────────────────────────────────────┘
```
### Card with Personality
```
┌───────────────────────────────────┐
│ ┌──────┐ │
│ │ icon │ user/repo │
│ │ │ :latest [⚓ Helm] │ Anchor icon for Helm
│ └──────┘ │
│ │
│ A container that does amazing │
│ things for your app... │
│ │
│ sha256:abcdef12 │
│ ─────────────────────────────────│
│ ★ 12 ↓ 340 1 day ago │
│ │
│ 🐴 Shipped by alice.bsky.social │ Playful "shipped by" line
└───────────────────────────────────┘
(optional: "Shipped by" could be subtle or only on hover)
```
## Design Improvements
### 1. Enhanced Card Design (Priority: High)
**Files:** `pkg/appview/public/css/style.css`, `pkg/appview/templates/components/repo-card.html`
- Add subtle gradient backgrounds on hover
- Improve shadow depth (layered shadows for modern look)
- Add smooth transitions (transform, box-shadow)
- Better icon styling with ring/border accent
- Enhanced badge visibility with better contrast
- Add "Updated X ago" timestamp to cards
- Improve stat icon/count alignment and spacing
### 2. Hero Section Polish (Priority: High)
**Files:** `pkg/appview/public/css/style.css`, `pkg/appview/templates/pages/home.html`
- Add subtle background pattern or gradient mesh
- Improve terminal mockup styling (better shadows, glow effect)
- Enhance benefit cards with icons and better spacing
- Add visual separation between hero and content
- Improve CTA button styling with better hover states
### 3. Typography & Spacing (Priority: High)
**Files:** `pkg/appview/public/css/style.css`
- Increase visual hierarchy with better font weights
- Add more breathing room (padding/margins)
- Improve heading styles with subtle underlines or accents
- Better link styling with hover states
- Add letter-spacing to badges for readability
### 4. Badge System Enhancement (Priority: Medium)
**Files:** `pkg/appview/public/css/style.css`, templates
- Create unified badge design language
- Add subtle icons inside badges (already using Lucide)
- Improve color coding: Helm (blue), Attestation (green), Multi-arch (purple)
- Add "Official" or "Verified" badge styling (for future use)
- Better hover states on interactive badges
### 5. Featured Section Improvements (Priority: Medium)
**Files:** `pkg/appview/templates/pages/home.html`, `pkg/appview/public/css/style.css`
- Add section header with subtle styling
- Improve grid responsiveness
- Add "View All" link styling
- Better visual distinction from "What's New" section
### 6. Navigation Polish (Priority: Medium)
**Files:** `pkg/appview/public/css/style.css`, nav templates
- Improve search bar visibility and styling
- Better user menu dropdown aesthetics
- Add subtle border or shadow to navbar
- Improve mobile responsiveness
### 7. Loading & Empty States (Priority: Low)
**Files:** `pkg/appview/public/css/style.css`
- Add skeleton loading animations
- Improve empty state illustrations/styling
- Better transition when content loads
### 8. Micro-interactions (Priority: Low)
**Files:** `pkg/appview/public/css/style.css`, `pkg/appview/public/js/app.js`
- Add subtle hover animations throughout
- Improve button press feedback
- Star button animation on click
- Copy button success animation
## Implementation Order
1. **Phase 1: Core Card Styling**
- Update `.featured-card` with modern shadows and transitions
- Enhance badge styling in `style.css`
- Add hover effects and transforms
2. **Phase 2: Hero & Featured Section**
- Improve hero section gradient/background
- Polish benefit cards
- Add section separators
3. **Phase 3: Typography & Spacing**
- Update font weights and sizes
- Improve padding throughout
- Better visual rhythm
4. **Phase 4: Navigation & Polish**
- Navbar improvements
- Loading states
- Final micro-interactions
## Key CSS Changes
### Tile Grid Layout
```css
.featured-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* Already exists but updating min-width */
.featured-card {
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
```
### Enhanced Shadow System (Multi-layer for depth)
```css
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.05);
--shadow-card-hover: 0 8px 25px rgba(78,205,196,0.15), 0 4px 12px rgba(0,0,0,0.1);
--shadow-nav: 0 2px 8px rgba(0,0,0,0.1);
```
### Card Design Enhancement
```css
.featured-card {
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
border: 1px solid var(--border);
}
.featured-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-card-hover);
border-color: var(--primary); /* teal accent on hover */
}
```
### Icon Container Styling
```css
.featured-icon-placeholder {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 2px 8px rgba(78,205,196,0.3);
}
```
### Badge System (Consistent, Accessible)
```css
.badge-helm {
background: #0d6cbf;
color: #fff;
}
.badge-multi {
background: #7c3aed;
color: #fff;
}
.badge-attestation {
background: #059669;
color: #fff;
}
/* All badges: */
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
```
### Hero Section Enhancement
```css
.hero-section {
background:
linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 50%, rgba(78,205,196,0.1) 100%),
url('/static/wave-pattern.svg'); /* subtle wave pattern */
background-size: cover, 100% 50px;
background-position: center, bottom;
background-repeat: no-repeat, repeat-x;
}
.benefit-card {
border: 1px solid transparent;
border-radius: 12px; /* softer corners */
transition: all 0.2s ease;
}
.benefit-card:hover {
border-color: var(--primary);
transform: translateY(-4px);
}
```
### Playful Border Radius (Softer Feel)
```css
:root {
--radius-sm: 6px; /* was 4px */
--radius-md: 12px; /* was 8px */
--radius-lg: 16px; /* new */
}
.featured-card { border-radius: var(--radius-md); }
.benefit-card { border-radius: var(--radius-md); }
.btn { border-radius: var(--radius-sm); }
.hero-terminal { border-radius: var(--radius-lg); }
```
### Fun Empty States
```css
.empty-state {
text-align: center;
padding: 3rem;
}
.empty-state-mascot {
width: 120px;
height: auto;
margin-bottom: 1.5rem;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.empty-state-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--fg);
}
.empty-state-text {
color: var(--secondary);
margin-bottom: 1.5rem;
}
```
### Typography Refinements
```css
.featured-title {
font-weight: 600;
letter-spacing: -0.01em;
}
.featured-description {
line-height: 1.5;
opacity: 0.85;
}
```
## Data Model Change
**Current "What's New":** Shows individual pushes (each tag push is a separate card)
**Proposed "What's New":** Shows repos ordered by last update time (same as Featured, different sort)
**Tracking:** `repository_stats` table already has `last_push` timestamp!
```sql
SELECT * FROM repository_stats ORDER BY last_push DESC LIMIT 9;
```
**Unified Card Data:**
| Field | Source |
|-------|--------|
| Handle, Repository | users + manifests |
| Tag | Latest tag from `tags` table |
| Digest | From latest tag or manifest |
| Description, IconURL | repo_pages or annotations |
| StarCount, PullCount | stars count + repository_stats |
| LastUpdated | `repository_stats.last_push` |
| ArtifactType | manifests.artifact_type |
## Files to Modify
| File | Changes |
|------|---------|
| `pkg/appview/public/css/style.css` | Rounded corners, shadows, hover, badges, ocean theme |
| `pkg/appview/public/wave-pattern.svg` | NEW: Subtle wave pattern for hero background |
| `pkg/appview/templates/components/repo-card.html` | Add Tag, Digest, LastUpdated fields |
| `pkg/appview/templates/components/empty-state.html` | NEW: Reusable fun empty state with mascot |
| `pkg/appview/templates/pages/home.html` | Both sections use repo-card grid |
| `pkg/appview/templates/pages/404.html` | Fun "Lost at sea" error page |
| `pkg/appview/db/queries.go` | New `GetRecentlyUpdatedRepos()` query; add fields to `RepoCardData` |
| `pkg/appview/handlers/home.go` | Replace `GetRecentPushes` with `GetRecentlyUpdatedRepos` |
| `pkg/appview/templates/partials/push-list.html` | Delete or repurpose (no longer needed) |
## Dependencies
**Mascot Art Needed:**
- `seahorse-empty.svg` - Friendly pose for "nothing here yet" empty states
- `seahorse-confused.svg` - Lost/confused pose for 404 errors
- `seahorse-waving.svg` (optional) - For hero section accent
**Can proceed without art:**
- CSS changes (colors, shadows, rounded corners, gradients)
- Card layout and grid changes
- Data layer changes (queries, handlers)
- Wave pattern background (simple SVG)
**Blocked until art is ready:**
- Empty state component with mascot
- 404 page redesign with mascot
- Hero mascot integration (optional)
## Implementation Phases
### Phase 1: CSS & Layout (No art needed)
1. Update border-radius variables (softer corners)
2. New shadow system
3. Card hover effects with teal accent
4. Tile grid layout (`minmax(280px, 1fr)`)
5. Wave pattern SVG for hero background
### Phase 2: Card Component & Data
1. Update `repo-card.html` with new structure
2. Add `Digest`, `Tag`, `CreatedAt` fields
3. Update queries for latest manifest info
4. Replace push list with card grid
### Phase 3: Hero & Section Polish
1. Hero gradient + wave pattern
2. Benefit card improvements
3. Section headers and spacing
4. Mobile responsive breakpoints
### Phase 4: Mascot Integration (BLOCKED - needs art)
1. Empty state component with mascot
2. 404 page with confused seahorse
3. Hero mascot (optional)
### Phase 5: Testing
1. Dark mode verification
2. Mobile responsive check
3. All functionality works (stars, links, copy)
## Verification
1. **Visual check on homepage** - cards have depth and polish
2. **Hover states** - smooth transitions on cards, buttons, badges
3. **Dark mode** - all changes work in both themes
4. **Mobile** - responsive at all breakpoints
5. **Functionality** - stars, search, navigation all work
6. **Performance** - no jank from CSS transitions
7. **Accessibility** - badge text readable (contrast check)
+1573
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "atcr-styles",
"version": "1.0.0",
"private": true,
"scripts": {
"css:build": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --minify",
"css:watch": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --watch",
"js:build": "esbuild pkg/appview/src/js/main.js --bundle --minify --format=esm --outfile=pkg/appview/public/js/bundle.min.js",
"js:watch": "esbuild pkg/appview/src/js/main.js --bundle --watch --format=esm --outfile=pkg/appview/public/js/bundle.min.js",
"build": "npm run css:build && npm run js:build",
"watch": "npm run css:watch & npm run js:watch"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.18",
"daisyui": "^5.5.14",
"esbuild": "^0.27.2",
"tailwindcss": "^4.1"
},
"dependencies": {
"actor-typeahead": "^0.1.2",
"htmx.org": "^2.0.8",
"lucide": "^0.562.0"
}
}
+5 -2
View File
@@ -137,8 +137,11 @@ type RepoCardData struct {
IconURL string
StarCount int
PullCount int
IsStarred bool // Whether the current user has starred this repository
ArtifactType string // container-image, helm-chart, unknown
IsStarred bool // Whether the current user has starred this repository
ArtifactType string // container-image, helm-chart, unknown
Tag string // Latest tag name (e.g., "latest", "v1.0.0")
Digest string // Latest manifest digest (sha256:...)
LastUpdated time.Time // When the repository was last pushed to
}
// PlatformInfo represents platform information (OS/Architecture)
+182
View File
@@ -1736,6 +1736,188 @@ func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]Fe
return featured, nil
}
// RepoCardSortOrder specifies how repo cards should be sorted
type RepoCardSortOrder string
const (
// SortByScore sorts by combined stars and pulls (for Featured)
SortByScore RepoCardSortOrder = "score"
// SortByLastUpdate sorts by most recent push (for What's New)
SortByLastUpdate RepoCardSortOrder = "last_update"
)
// GetRepoCards fetches repository cards with full data including Tag, Digest, and LastUpdated
func GetRepoCards(db *sql.DB, limit int, currentUserDID string, sortOrder RepoCardSortOrder) ([]RepoCardData, error) {
// Build ORDER BY clause based on sort order
var orderBy string
switch sortOrder {
case SortByLastUpdate:
orderBy = "COALESCE(rs.last_push, m.created_at) DESC"
default: // SortByScore
orderBy = "repo_stats.score DESC, repo_stats.star_count DESC, repo_stats.pull_count DESC, m.created_at DESC"
}
query := `
WITH latest_manifests AS (
SELECT did, repository, MAX(id) as latest_id
FROM manifests
GROUP BY did, repository
),
repo_stats AS (
SELECT
lm.did,
lm.repository,
COALESCE(rs.pull_count, 0) as pull_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) as star_count,
(COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) * 10) as score
FROM latest_manifests lm
LEFT JOIN repository_stats rs ON lm.did = rs.did AND lm.repository = rs.repository
)
SELECT
m.did,
u.handle,
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
repo_stats.star_count,
repo_stats.pull_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
COALESCE(rs.last_push, m.created_at),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
JOIN users u ON m.did = u.did
JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
ORDER BY ` + orderBy + `
LIMIT ?
`
rows, err := db.Query(query, currentUserDID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
return cards, nil
}
// GetUserRepoCards fetches repository cards for a specific user with full data
func GetUserRepoCards(db *sql.DB, userDID string, currentUserDID string) ([]RepoCardData, error) {
query := `
WITH latest_manifests AS (
SELECT did, repository, MAX(id) as latest_id
FROM manifests
WHERE did = ?
GROUP BY did, repository
),
repo_stats AS (
SELECT
lm.did,
lm.repository,
COALESCE(rs.pull_count, 0) as pull_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) as star_count
FROM latest_manifests lm
LEFT JOIN repository_stats rs ON lm.did = rs.did AND lm.repository = rs.repository
)
SELECT
m.did,
u.handle,
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
repo_stats.star_count,
repo_stats.pull_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
COALESCE(rs.last_push, m.created_at),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
JOIN users u ON m.did = u.did
JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
ORDER BY COALESCE(rs.last_push, m.created_at) DESC
`
rows, err := db.Query(query, userDID, currentUserDID)
if err != nil {
return nil, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
return cards, nil
}
// RepoPage represents a repository page record cached from PDS
type RepoPage struct {
DID string
+57 -2
View File
@@ -1,9 +1,11 @@
package handlers
import (
"bytes"
"database/sql"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
@@ -21,6 +23,7 @@ type StarRepositoryHandler struct {
DB *sql.DB
Directory identity.Directory
Refresher *oauth.Refresher
Templates *template.Template
}
func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -64,7 +67,21 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
// Return success
// Check if HTMX request - return HTML component
if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
// Get current star count and do optimistic increment
stats, _ := db.GetRepositoryStats(h.DB, ownerDID, repository)
starCount := 0
if stats != nil {
starCount = stats.StarCount
}
starCount++ // Optimistic increment
renderStarComponent(w, h.Templates, handle, repository, true, starCount)
return
}
// Return JSON for API clients
w.WriteHeader(http.StatusCreated)
render.JSON(w, r, map[string]bool{"starred": true})
}
@@ -74,6 +91,7 @@ type UnstarRepositoryHandler struct {
DB *sql.DB
Directory identity.Directory
Refresher *oauth.Refresher
Templates *template.Template
}
func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -119,7 +137,23 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
slog.Debug("Star record not found, already unstarred")
}
// Return success
// Check if HTMX request - return HTML component
if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
// Get current star count and do optimistic decrement
stats, _ := db.GetRepositoryStats(h.DB, ownerDID, repository)
starCount := 0
if stats != nil {
starCount = stats.StarCount
}
if starCount > 0 {
starCount-- // Optimistic decrement
}
renderStarComponent(w, h.Templates, handle, repository, false, starCount)
return
}
// Return JSON for API clients
render.JSON(w, r, map[string]bool{"starred": false})
}
@@ -264,3 +298,24 @@ func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *htt
w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes
render.JSON(w, r, response)
}
// renderStarComponent renders the star component HTML for HTMX responses
func renderStarComponent(w http.ResponseWriter, tmpl *template.Template, handle, repository string, isStarred bool, starCount int) {
data := map[string]any{
"Interactive": true,
"Handle": handle,
"Repository": repository,
"IsStarred": isStarred,
"StarCount": starCount,
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "star", data); err != nil {
slog.Error("Failed to render star component", "error", err)
http.Error(w, "Failed to render component", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}
+28 -19
View File
@@ -6,6 +6,7 @@ package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"strconv"
@@ -14,6 +15,13 @@ import (
"atcr.io/pkg/appview/middleware"
)
// BenefitCard represents a feature benefit card on the home page
type BenefitCard struct {
Icon string
Title string
Description string
}
// HomeHandler handles the home page
type HomeHandler struct {
DB *sql.DB
@@ -28,35 +36,36 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
currentUserDID = user.DID
}
// Fetch featured repositories (top 6)
featured, err := db.GetFeaturedRepositories(h.DB, 6, currentUserDID)
// Fetch featured repositories (top 6 by score - carousel cycles through them)
featuredCards, err := db.GetRepoCards(h.DB, 6, currentUserDID, db.SortByScore)
if err != nil {
// Log error but continue - featured section will be empty
featured = []db.FeaturedRepository{}
log.Printf("Error fetching featured repos: %v", err)
featuredCards = []db.RepoCardData{}
}
// Convert to RepoCardData for template
cards := make([]db.RepoCardData, len(featured))
for i, repo := range featured {
cards[i] = db.RepoCardData{
OwnerHandle: repo.OwnerHandle,
Repository: repo.Repository,
Title: repo.Title,
Description: repo.Description,
IconURL: repo.IconURL,
StarCount: repo.StarCount,
PullCount: repo.PullCount,
IsStarred: repo.IsStarred,
ArtifactType: repo.ArtifactType,
}
// Fetch recently updated repositories (top 18 by last push - 6 rows)
recentCards, err := db.GetRepoCards(h.DB, 18, currentUserDID, db.SortByLastUpdate)
if err != nil {
log.Printf("Error fetching recent repos: %v", err)
recentCards = []db.RepoCardData{}
}
benefits := []BenefitCard{
{Icon: "ship", Title: "Works with Docker", Description: "Use docker push & pull. No new tools to learn."},
{Icon: "anchor", Title: "Your Data", Description: "Join shared holds or captain your own storage."},
{Icon: "compass", Title: "Discover Images", Description: "Browse and star public container registries."},
}
data := struct {
PageData
FeaturedRepos []db.RepoCardData
RecentRepos []db.RepoCardData
Benefits []BenefitCard
}{
PageData: NewPageData(r, h.RegistryURL),
FeaturedRepos: cards,
FeaturedRepos: featuredCards,
RecentRepos: recentCards,
Benefits: benefits,
}
if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil {
+2
View File
@@ -245,6 +245,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
Tags []db.TagWithPlatforms // Tags with platform info
Manifests []db.ManifestWithMetadata // Top-level manifests only
StarCount int
PullCount int
IsStarred bool
IsOwner bool // Whether current user owns this repository
ReadmeHTML template.HTML
@@ -256,6 +257,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
Tags: tagsWithPlatforms,
Manifests: manifests,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
IsStarred: isStarred,
IsOwner: isOwner,
ReadmeHTML: readmeHTML,
+11 -25
View File
@@ -3,9 +3,11 @@ package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
@@ -50,33 +52,17 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
viewedUser.Handle = resolvedHandle
}
// Fetch repositories for this user
repos, err := db.GetUserRepositories(h.DB, viewedUser.DID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
// Get current user DID for star state (empty string if not logged in)
var currentUserDID string
if user := middleware.GetUser(r); user != nil {
currentUserDID = user.DID
}
// Convert to RepoCardData for template
cards := make([]db.RepoCardData, 0, len(repos))
for _, repo := range repos {
stats, err := db.GetRepositoryStats(h.DB, viewedUser.DID, repo.Name)
if err != nil {
// Continue with zero stats on error
stats = &db.RepositoryStats{
DID: viewedUser.DID,
Repository: repo.Name,
}
}
cards = append(cards, db.RepoCardData{
OwnerHandle: viewedUser.Handle,
Repository: repo.Name,
Title: repo.Title,
Description: repo.Description,
IconURL: repo.IconURL,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
})
// Fetch repository cards for this user
cards, err := db.GetUserRepoCards(h.DB, viewedUser.DID, currentUserDID)
if err != nil {
log.Printf("Error fetching repo cards for user %s: %v", viewedUser.DID, err)
cards = []db.RepoCardData{}
}
data := struct {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path
fill="rgba(78, 205, 196, 0.25)"
d="M0,30 C120,50 240,10 360,30 C480,50 600,10 720,30 C840,50 960,10 1080,30 C1200,50 1320,10 1440,30 L1440,60 L0,60 Z"
/>
<path
fill="rgba(78, 205, 196, 0.15)"
d="M0,35 C180,55 360,15 540,35 C720,55 900,15 1080,35 C1260,55 1440,35 1440,35 L1440,60 L0,60 Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

+3
View File
@@ -112,11 +112,13 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
).ServeHTTP)
// API routes for stars (require authentication)
// Returns HTML for HTMX requests, JSON for API clients
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.StarRepositoryHandler{
DB: deps.Database, // Needs write access
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
Templates: deps.Templates,
},
).ServeHTTP)
@@ -125,6 +127,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
DB: deps.Database, // Needs write access
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
Templates: deps.Templates,
},
).ServeHTTP)
+143
View File
@@ -0,0 +1,143 @@
/* ========================================
TAILWIND + DAISYUI
======================================== */
@import "tailwindcss";
/*@layer base {
.container {
max-width: 1920px;
}
}*/
@plugin "daisyui" {
themes:
light --default,
dark --prefersdark;
}
/* ========================================
BRAND COLOR OVERRIDES
======================================== */
@plugin "daisyui/theme" {
name: "light";
default: true;
--color-primary: oklch(75% 0.12 175); /* #4ECDC4 teal */
--color-accent: oklch(68% 0.18 25); /* #FF6B6B coral */
}
@plugin "daisyui/theme" {
name: "dark";
--color-primary: oklch(78% 0.12 175); /* #5ED4CB slightly brighter */
--color-accent: oklch(72% 0.16 25); /* #FF8080 */
}
/* ========================================
ADDITIONAL CSS VARIABLES
======================================== */
:root {
--shadow-card-hover:
0 8px 25px oklch(75% 0.12 175 / 0.15), 0 4px 12px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--shadow-card-hover:
0 8px 25px oklch(78% 0.12 175 / 0.1), 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* ========================================
CUSTOM COMPONENTS (Not in DaisyUI)
======================================== */
@layer components {
/* ----------------------------------------
COMMAND / CODE DISPLAY
---------------------------------------- */
.cmd {
@apply flex items-center gap-2 relative w-full overflow-hidden;
@apply bg-base-200 border border-base-300 rounded-md;
@apply px-3 py-2;
}
.cmd code {
@apply font-mono text-sm truncate;
}
/* ----------------------------------------
EXPANDABLE SEARCH (nav-specific)
---------------------------------------- */
.nav-search-wrapper {
@apply relative flex items-center;
}
.nav-search-form {
@apply absolute right-full mr-2;
@apply w-0 opacity-0 overflow-hidden;
@apply transition-all duration-300;
}
.nav-search-wrapper.expanded .nav-search-form {
@apply w-62 opacity-100;
}
/* ----------------------------------------
CARD EXTENSIONS
---------------------------------------- */
.card-interactive {
@apply cursor-pointer transition-all duration-500;
}
.card-interactive:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
/* ----------------------------------------
ACTOR-TYPEAHEAD COMPONENT STYLING
---------------------------------------- */
actor-typeahead {
/* Use DaisyUI CSS variables - they auto-switch with theme */
--color-background: var(--color-base-100);
--color-border: var(--color-base-300);
--color-shadow: var(--color-base-content);
--color-hover: var(--color-base-200);
--color-avatar-fallback: var(--color-base-300);
--radius: 0.5rem;
--padding-menu: 0.25rem;
z-index: 50;
}
actor-typeahead::part(handle) {
@apply text-base-content;
}
actor-typeahead::part(menu) {
@apply shadow-lg;
margin-top: 0.25rem;
}
/* ----------------------------------------
RECENT ACCOUNTS DROPDOWN
---------------------------------------- */
.recent-accounts-dropdown {
@apply absolute top-full left-0 right-0;
@apply bg-base-100 border border-base-300;
@apply rounded-lg shadow-lg;
@apply max-h-60 overflow-y-auto z-50;
margin-top: 0.25rem;
}
.recent-accounts-header {
@apply px-3 py-2 text-xs font-semibold uppercase;
@apply text-base-content/60 border-b border-base-300;
}
.recent-accounts-item {
@apply px-3 py-2.5;
@apply cursor-pointer transition-colors duration-150;
@apply text-base-content;
}
.recent-accounts-item:hover,
.recent-accounts-item.focused {
@apply bg-base-200;
}
}
@@ -1,40 +1,64 @@
// Theme management
// Load theme immediately to avoid flash
(function() {
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme') || 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon();
// Theme management (system / light / dark)
function getThemePreference() {
return localStorage.getItem('theme') || 'system';
}
function updateThemeIcon() {
const themeBtn = document.getElementById('theme-toggle');
if (!themeBtn) return;
function getEffectiveTheme(pref) {
if (pref === 'dark') return 'dark';
if (pref === 'light') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
const icon = themeBtn.querySelector('.theme-icon');
function applyTheme() {
const pref = getThemePreference();
const effective = getEffectiveTheme(pref);
document.documentElement.classList.toggle('dark', effective === 'dark');
document.documentElement.setAttribute('data-theme', effective);
updateThemeUI(pref);
}
function setTheme(theme) {
localStorage.setItem('theme', theme);
applyTheme();
closeThemeDropdown();
}
function updateThemeUI(pref) {
// Update nav button icon to show selected preference
const iconMap = { system: 'sun-moon', light: 'sun', dark: 'moon' };
const icon = document.getElementById('theme-icon');
if (icon) {
// In dark mode, show sun icon (to switch to light)
// In light mode, show moon icon (to switch to dark)
icon.setAttribute('data-lucide', currentTheme === 'dark' ? 'sun' : 'moon');
// Re-initialize Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
icon.setAttribute('data-lucide', iconMap[pref] || 'sun-moon');
if (typeof window.lucide !== 'undefined') {
window.lucide.createIcons();
}
}
themeBtn.setAttribute('aria-label', currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
// Update checkmarks in dropdown
document.querySelectorAll('.theme-option').forEach(option => {
const isSelected = option.dataset.value === pref;
const check = option.querySelector('.theme-check');
if (check) {
check.style.visibility = isSelected ? 'visible' : 'hidden';
}
});
}
function closeThemeDropdown() {
const btn = document.getElementById('theme-toggle-btn');
const details = btn?.closest('details');
if (details) details.removeAttribute('open');
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getThemePreference() === 'system') {
applyTheme();
}
});
// Expandable search
function toggleSearch() {
const wrapper = document.querySelector('.nav-search-wrapper');
@@ -87,14 +111,14 @@ function copyToClipboard(text) {
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i data-lucide="check"></i> Copied!';
// Re-initialize Lucide icons for the new icon
if (typeof lucide !== 'undefined') {
lucide.createIcons();
if (typeof window.lucide !== 'undefined') {
window.lucide.createIcons();
}
setTimeout(() => {
btn.innerHTML = originalHTML;
// Re-initialize Lucide icons to restore original icon
if (typeof lucide !== 'undefined') {
lucide.createIcons();
if (typeof window.lucide !== 'undefined') {
window.lucide.createIcons();
}
}, 2000);
}).catch(err => {
@@ -139,10 +163,22 @@ function updateTimestamps() {
});
}
// Initial timestamp update
// Initial timestamp update and theme setup
document.addEventListener('DOMContentLoaded', () => {
updateTimestamps();
updateThemeIcon();
applyTheme();
// Theme dropdown setup - DaisyUI details handles open/close natively
const themeMenu = document.getElementById('theme-dropdown-menu');
if (themeMenu) {
// Handle theme option clicks
themeMenu.querySelectorAll('.theme-option').forEach(option => {
option.addEventListener('click', () => {
setTheme(option.dataset.value);
});
});
}
});
// Update timestamps after HTMX swaps
@@ -151,196 +187,6 @@ document.addEventListener('htmx:afterSwap', updateTimestamps);
// Update timestamps periodically
setInterval(updateTimestamps, 60000); // Every minute
// Toggle repository details (for images page)
function toggleRepo(name) {
const details = document.getElementById('repo-' + name);
const btn = document.getElementById('btn-' + name);
if (details.style.display === 'none') {
details.style.display = 'block';
btn.innerHTML = '<i data-lucide="chevron-up"></i>';
} else {
details.style.display = 'none';
btn.innerHTML = '<i data-lucide="chevron-down"></i>';
}
// Re-initialize Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
// User dropdown menu
document.addEventListener('DOMContentLoaded', () => {
const menuBtn = document.getElementById('user-menu-btn');
const dropdownMenu = document.getElementById('user-dropdown-menu');
if (menuBtn && dropdownMenu) {
// Toggle dropdown on button click
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isExpanded = menuBtn.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
closeDropdown();
} else {
openDropdown();
}
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!menuBtn.contains(e.target) && !dropdownMenu.contains(e.target)) {
closeDropdown();
}
});
// Close dropdown on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDropdown();
}
});
function openDropdown() {
menuBtn.setAttribute('aria-expanded', 'true');
dropdownMenu.removeAttribute('hidden');
}
function closeDropdown() {
menuBtn.setAttribute('aria-expanded', 'false');
dropdownMenu.setAttribute('hidden', '');
}
}
});
// Toggle star on a repository
async function toggleStar(handle, repository) {
const starBtn = document.getElementById('star-btn');
const starIcon = document.getElementById('star-icon');
const starCountEl = document.getElementById('star-count');
if (!starBtn || !starIcon || !starCountEl) return;
// Disable button during request
starBtn.disabled = true;
try {
// Check current state
const isStarred = starIcon.classList.contains('star-filled');
const method = isStarred ? 'DELETE' : 'POST';
const url = `/api/stars/${handle}/${repository}`;
const response = await fetch(url, {
method: method,
credentials: 'include',
});
if (response.status === 401) {
console.log('Not authenticated, redirecting to login');
// Not authenticated, redirect to login
window.location.href = '/auth/oauth/login';
return;
}
if (!response.ok) {
const errorText = await response.text();
console.error(`Toggle star failed: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Failed to toggle star: ${errorText}`);
}
const data = await response.json();
// Update UI optimistically
if (data.starred) {
starIcon.classList.add('star-filled');
starBtn.classList.add('starred');
// Optimistically increment count
const currentCount = parseInt(starCountEl.textContent) || 0;
starCountEl.textContent = currentCount + 1;
} else {
starIcon.classList.remove('star-filled');
starBtn.classList.remove('starred');
// Optimistically decrement count
const currentCount = parseInt(starCountEl.textContent) || 0;
starCountEl.textContent = Math.max(0, currentCount - 1);
}
// Don't fetch count immediately - trust the optimistic update
// The actual count will be correct on next page load
} catch (err) {
console.error('Error toggling star:', err);
alert(`Failed to toggle star: ${err.message}`);
} finally {
starBtn.disabled = false;
}
}
// Load star status and count for current repository
async function loadStarStatus() {
const starBtn = document.getElementById('star-btn');
const starIcon = document.getElementById('star-icon');
if (!starBtn || !starIcon) return; // Not on repository page
// Extract handle and repository from button onclick attribute
const onclick = starBtn.getAttribute('onclick');
const match = onclick.match(/toggleStar\('([^']+)',\s*'([^']+)'\)/);
if (!match) return;
const handle = match[1];
const repository = match[2];
try {
// Check if user has starred this repo
const starResponse = await fetch(`/api/stars/${handle}/${repository}`, {
credentials: 'include',
});
if (starResponse.ok) {
const starData = await starResponse.json();
console.log('Star status data:', starData);
if (starData.starred) {
starIcon.classList.add('star-filled');
starBtn.classList.add('starred');
}
} else {
const errorText = await starResponse.text();
console.error('Failed to load star status:', errorText);
}
// Load star count
await loadStarCount(handle, repository);
} catch (err) {
console.error('Error loading star status:', err);
}
}
// Load star count for a repository
async function loadStarCount(handle, repository) {
const starCountEl = document.getElementById('star-count');
if (!starCountEl) return;
try {
const statsResponse = await fetch(`/api/stats/${handle}/${repository}`, {
credentials: 'include',
});
if (statsResponse.ok) {
const stats = await statsResponse.json();
console.log('Stats data:', stats);
starCountEl.textContent = stats.star_count || 0;
} else {
const errorText = await statsResponse.text();
console.error('Failed to load stats:', errorText);
}
} catch (err) {
console.error('Error loading star count:', err);
}
}
// Toggle offline manifests visibility
function toggleOfflineManifests() {
const checkbox = document.getElementById('show-offline-toggle');
@@ -553,28 +399,28 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Login page typeahead functionality
class LoginTypeahead {
// Login page recent accounts helper (works alongside actor-typeahead web component)
class RecentAccountsHelper {
constructor(inputElement) {
this.input = inputElement;
this.typeahead = inputElement.closest('actor-typeahead');
this.dropdown = null;
this.debounceTimer = null;
this.currentFocus = -1;
this.results = [];
this.isLoading = false;
this.init();
}
init() {
// Create dropdown element
this.createDropdown();
// Event listeners
this.input.addEventListener('input', (e) => this.handleInput(e));
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
// Show recent accounts on focus when input is empty
this.input.addEventListener('focus', () => this.handleFocus());
// Hide recent accounts when user starts typing (actor-typeahead takes over)
this.input.addEventListener('input', () => this.handleInput());
// Keyboard navigation for recent accounts dropdown
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
@@ -585,121 +431,31 @@ class LoginTypeahead {
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'typeahead-dropdown';
this.dropdown.className = 'recent-accounts-dropdown';
this.dropdown.style.display = 'none';
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
}
handleInput(e) {
const value = e.target.value.trim();
// Clear debounce timer
clearTimeout(this.debounceTimer);
if (value.length < 2) {
this.showRecentAccounts();
return;
// Insert after the actor-typeahead element
if (this.typeahead) {
this.typeahead.insertAdjacentElement('afterend', this.dropdown);
} else {
this.input.insertAdjacentElement('afterend', this.dropdown);
}
// Debounce API call (200ms)
this.debounceTimer = setTimeout(() => {
this.searchActors(value);
}, 200);
}
handleFocus() {
const value = this.input.value.trim();
if (value.length < 2) {
if (value.length < 1) {
this.showRecentAccounts();
}
}
async searchActors(query) {
this.isLoading = true;
this.showLoading();
try {
const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=3`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch suggestions');
}
const data = await response.json();
this.results = data.actors || [];
this.renderResults();
} catch (err) {
console.error('Typeahead error:', err);
handleInput() {
const value = this.input.value.trim();
// Hide recent accounts once user starts typing (actor-typeahead shows its menu at 2+ chars)
if (value.length >= 1) {
this.hideDropdown();
} finally {
this.isLoading = false;
}
}
showLoading() {
this.dropdown.innerHTML = '<div class="typeahead-loading">Searching...</div>';
this.dropdown.style.display = 'block';
}
renderResults() {
if (this.results.length === 0) {
this.hideDropdown();
return;
}
this.dropdown.innerHTML = '';
this.currentFocus = -1;
this.results.slice(0, 3).forEach((actor, index) => {
const item = this.createResultItem(actor, index);
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
createResultItem(actor, index) {
const item = document.createElement('div');
item.className = 'typeahead-item';
item.dataset.index = index;
item.dataset.handle = actor.handle;
// Avatar
const avatar = document.createElement('img');
avatar.className = 'typeahead-avatar';
avatar.src = actor.avatar || '/static/images/default-avatar.png';
avatar.alt = actor.handle;
avatar.onerror = () => {
avatar.src = '/static/images/default-avatar.png';
};
// Text container
const textContainer = document.createElement('div');
textContainer.className = 'typeahead-text';
// Display name
const displayName = document.createElement('div');
displayName.className = 'typeahead-displayname';
displayName.textContent = actor.displayName || actor.handle;
// Handle
const handle = document.createElement('div');
handle.className = 'typeahead-handle';
handle.textContent = `@${actor.handle}`;
textContainer.appendChild(displayName);
textContainer.appendChild(handle);
item.appendChild(avatar);
item.appendChild(textContainer);
// Click handler
item.addEventListener('click', () => this.selectItem(actor.handle));
return item;
}
showRecentAccounts() {
const recent = this.getRecentAccounts();
if (recent.length === 0) {
@@ -711,28 +467,17 @@ class LoginTypeahead {
this.currentFocus = -1;
const header = document.createElement('div');
header.className = 'typeahead-header';
header.className = 'recent-accounts-header';
header.textContent = 'Recent accounts';
this.dropdown.appendChild(header);
recent.forEach((handle, index) => {
const item = document.createElement('div');
item.className = 'typeahead-item typeahead-recent';
item.className = 'recent-accounts-item';
item.dataset.index = index;
item.dataset.handle = handle;
const textContainer = document.createElement('div');
textContainer.className = 'typeahead-text';
const handleDiv = document.createElement('div');
handleDiv.className = 'typeahead-handle';
handleDiv.textContent = handle;
textContainer.appendChild(handleDiv);
item.appendChild(textContainer);
item.textContent = handle;
item.addEventListener('click', () => this.selectItem(handle));
this.dropdown.appendChild(item);
});
@@ -742,9 +487,7 @@ class LoginTypeahead {
selectItem(handle) {
this.input.value = handle;
this.hideDropdown();
this.saveRecentAccount(handle);
// Optionally submit the form automatically
// this.input.form.submit();
this.input.focus();
}
hideDropdown() {
@@ -753,21 +496,9 @@ class LoginTypeahead {
}
handleKeydown(e) {
// If dropdown is hidden, only respond to ArrowDown to show it
if (this.dropdown.style.display === 'none') {
if (e.key === 'ArrowDown') {
e.preventDefault();
const value = this.input.value.trim();
if (value.length >= 2) {
this.searchActors(value);
} else {
this.showRecentAccounts();
}
}
return;
}
if (this.dropdown.style.display === 'none') return;
const items = this.dropdown.querySelectorAll('.typeahead-item');
const items = this.dropdown.querySelectorAll('.recent-accounts-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
@@ -779,12 +510,9 @@ class LoginTypeahead {
this.currentFocus--;
if (this.currentFocus < 0) this.currentFocus = items.length - 1;
this.updateFocus(items);
} else if (e.key === 'Enter') {
if (this.currentFocus > -1 && items[this.currentFocus]) {
e.preventDefault();
const handle = items[this.currentFocus].dataset.handle;
this.selectItem(handle);
}
} else if (e.key === 'Enter' && this.currentFocus > -1 && items[this.currentFocus]) {
e.preventDefault();
this.selectItem(items[this.currentFocus].dataset.handle);
} else if (e.key === 'Escape') {
this.hideDropdown();
}
@@ -792,11 +520,7 @@ class LoginTypeahead {
updateFocus(items) {
items.forEach((item, index) => {
if (index === this.currentFocus) {
item.classList.add('typeahead-focused');
} else {
item.classList.remove('typeahead-focused');
}
item.classList.toggle('focused', index === this.currentFocus);
});
}
@@ -810,13 +534,11 @@ class LoginTypeahead {
}
saveRecentAccount(handle) {
if (!handle) return;
try {
let recent = this.getRecentAccounts();
// Remove if already exists
recent = recent.filter(h => h !== handle);
// Add to front
recent.unshift(handle);
// Keep only last 5
recent = recent.slice(0, 5);
localStorage.setItem('atcr_recent_handles', JSON.stringify(recent));
} catch (err) {
@@ -825,10 +547,120 @@ class LoginTypeahead {
}
}
// Initialize typeahead on login page
// Initialize recent accounts helper on login page
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
const handleInput = document.getElementById('handle');
if (handleInput && handleInput.closest('.login-form')) {
new LoginTypeahead(handleInput);
if (loginForm && handleInput) {
new RecentAccountsHelper(handleInput);
}
});
// Save successful login handle from cookie (set by server after OAuth success)
document.addEventListener('DOMContentLoaded', () => {
const cookie = document.cookie.split('; ').find(c => c.startsWith('atcr_login_handle='));
if (!cookie) return;
const handle = decodeURIComponent(cookie.split('=')[1]);
if (handle) {
// Save to recent accounts
try {
const key = 'atcr_recent_handles';
let recent = JSON.parse(localStorage.getItem(key) || '[]');
recent = recent.filter(h => h !== handle);
recent.unshift(handle);
recent = recent.slice(0, 5);
localStorage.setItem(key, JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
// Delete the cookie
document.cookie = 'atcr_login_handle=; path=/; max-age=0';
}
});
// Featured carousel - scroll-based with proper wrap-around
document.addEventListener('DOMContentLoaded', () => {
const carousel = document.getElementById('featured-carousel');
const prevBtn = document.getElementById('carousel-prev');
const nextBtn = document.getElementById('carousel-next');
if (!carousel) return;
const items = Array.from(carousel.querySelectorAll('.carousel-item'));
if (items.length === 0) return;
let intervalId = null;
const intervalMs = 5000;
function getItemWidth() {
const item = items[0];
if (!item) return 0;
const style = getComputedStyle(carousel);
const gap = parseFloat(style.gap) || 24;
return item.offsetWidth + gap;
}
function getVisibleCount() {
const containerWidth = carousel.offsetWidth;
const itemWidth = getItemWidth();
if (itemWidth === 0) return 1;
return Math.round(containerWidth / itemWidth);
}
function getMaxScroll() {
return carousel.scrollWidth - carousel.offsetWidth;
}
function advance() {
const itemWidth = getItemWidth();
const maxScroll = getMaxScroll();
const currentScroll = carousel.scrollLeft;
// If we're at or near the end, wrap to start
if (currentScroll >= maxScroll - 10) {
carousel.scrollTo({ left: 0, behavior: 'smooth' });
} else {
carousel.scrollTo({ left: currentScroll + itemWidth, behavior: 'smooth' });
}
}
function retreat() {
const itemWidth = getItemWidth();
const maxScroll = getMaxScroll();
const currentScroll = carousel.scrollLeft;
// If we're at or near the start, wrap to end
if (currentScroll <= 10) {
carousel.scrollTo({ left: maxScroll, behavior: 'smooth' });
} else {
carousel.scrollTo({ left: currentScroll - itemWidth, behavior: 'smooth' });
}
}
if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); });
if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); });
function startInterval() {
if (intervalId || items.length <= getVisibleCount()) return;
intervalId = setInterval(advance, intervalMs);
}
function stopInterval() {
if (intervalId) { clearInterval(intervalId); intervalId = null; }
}
startInterval();
carousel.addEventListener('mouseenter', stopInterval);
carousel.addEventListener('mouseleave', startInterval);
});
// Export functions that are called from templates via onclick handlers
window.setTheme = setTheme;
window.toggleSearch = toggleSearch;
window.closeSearch = closeSearch;
window.copyToClipboard = copyToClipboard;
window.toggleOfflineManifests = toggleOfflineManifests;
window.deleteManifest = deleteManifest;
window.closeManifestDeleteModal = closeManifestDeleteModal;
window.uploadAvatar = uploadAvatar;
+93
View File
@@ -0,0 +1,93 @@
// HTMX
import htmx from 'htmx.org';
window.htmx = htmx;
// Actor Typeahead (web component, auto-registers on import)
import 'actor-typeahead';
// Lucide Icons (tree-shaken - only icons actually used in templates)
import { createIcons } from 'lucide';
import {
Anchor,
AlertCircle,
AlertTriangle,
ArrowDownToLine,
Box,
Check,
CheckCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleX,
Compass,
Copy,
Download,
Info,
Loader2,
Moon,
Package,
Plus,
RefreshCcw,
Search,
ShieldCheck,
Ship,
Star,
Sun,
SunMoon,
Terminal,
Trash2,
TriangleAlert,
XCircle,
} from 'lucide';
// Create icons map for createIcons function
const icons = {
Anchor,
AlertCircle,
AlertTriangle,
ArrowDownToLine,
Box,
Check,
CheckCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleX,
Compass,
Copy,
Download,
Info,
Loader2,
Moon,
Package,
Plus,
RefreshCcw,
Search,
ShieldCheck,
Ship,
Star,
Sun,
SunMoon,
Terminal,
Trash2,
TriangleAlert,
XCircle,
};
// Export lucide to window for templates that use lucide.createIcons()
window.lucide = {
createIcons: (opts = {}) => createIcons({ icons, ...opts }),
};
// Import app functionality
import './app.js';
// Initialize icons on DOM load
document.addEventListener('DOMContentLoaded', () => {
window.lucide.createIcons();
// Re-initialize icons after HTMX swaps content
document.body.addEventListener('htmx:afterSwap', () => {
window.lucide.createIcons();
});
});
File diff suppressed because it is too large Load Diff
@@ -5,11 +5,11 @@
Expects: string - the docker command to display
Usage: {{ template "docker-command" "docker pull atcr.io/alice/myapp:latest" }}
*/}}
<div class="docker-command">
<i data-lucide="terminal" class="docker-command-icon"></i>
<code class="docker-command-text">{{ . }}</code>
<button class="copy-btn" onclick="copyToClipboard(this.getAttribute('data-cmd'))" data-cmd="{{ . }}">
<i data-lucide="copy"></i>
<div class="cmd group" onclick="event.stopPropagation()">
<i data-lucide="terminal" class="size-4 shrink-0 text-base-content/60"></i>
<code>{{ . }}</code>
<button class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity" onclick="event.stopPropagation(); copyToClipboard(this.getAttribute('data-cmd'))" data-cmd="{{ . }}">
<i data-lucide="copy" class="size-4"></i>
</button>
</div>
{{ end }}
+18 -20
View File
@@ -9,26 +9,24 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Stylesheets -->
<!-- Theme: apply early to prevent flash -->
<script>
(function() {
function getEffectiveTheme(pref) {
if (pref === 'dark') return 'dark';
if (pref === 'light') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
var pref = localStorage.getItem('theme') || 'system';
var effective = getEffectiveTheme(pref);
document.documentElement.classList.toggle('dark', effective === 'dark');
document.documentElement.setAttribute('data-theme', effective);
})();
</script>
<!-- Tailwind CSS (built via npm run css:build) -->
<link rel="stylesheet" href="/css/style.css">
<!-- HTMX (vendored) -->
<script src="/js/htmx.min.js"></script>
<!-- Lucide Icons (vendored) -->
<script src="/js/lucide.min.js"></script>
<!-- App Scripts -->
<script src="/js/app.js"></script>
<script>
// Initialize Lucide icons after DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
// Re-initialize icons after HTMX swaps content
document.body.addEventListener('htmx:afterSwap', () => {
lucide.createIcons();
});
});
</script>
<!-- Bundled JS: HTMX + Lucide (tree-shaken) + Actor Typeahead + App -->
<script type="module" src="/js/bundle.min.js"></script>
{{ end }}
@@ -0,0 +1,40 @@
{{ define "hero" }}
{{/*
Hero section component - displays landing page hero for non-authenticated users
Required: .Benefits ([]Benefit with Icon, Title, Description fields)
*/}}
<section class="hero bg-base-200 min-h-[60vh] py-16 pb-24 relative">
<div class="hero-content text-center flex-col">
<h1 class="text-4xl md:text-5xl font-bold">ship containers on the open web.</h1>
<p class="text-lg text-base-content/70 max-w-lg mt-4">
Push and pull Docker images on the AT Protocol.<br>
Browse public registries or control your data.
</p>
<div class="mockup-code bg-base-300 text-left w-full max-w-lg text-base mt-8">
<pre data-prefix="$"><code>docker login atcr.io</code></pre>
<pre data-prefix="$"><code>docker push atcr.io/you/app</code></pre>
<pre data-prefix="#" class="text-base-content/50"><code>same docker, decentralized</code></pre>
</div>
<div class="flex items-center justify-center gap-4 mt-8">
<a href="/auth/oauth/login?return_to=/" class="btn btn-primary btn-lg">Get Started</a>
<a href="/install" class="btn btn-ghost btn-lg">Learn More</a>
</div>
<!-- Benefit Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl">
{{ range .Benefits }}
<div class="card bg-base-100 shadow-sm p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
<i data-lucide="{{ .Icon }}" class="size-8"></i>
</div>
<h3 class="font-semibold text-lg">{{ .Title }}</h3>
<p class="text-base-content/70 mt-2">{{ .Description }}</p>
</div>
{{ end }}
</div>
</div>
<img src="/static/wave-pattern.svg" alt="" class="absolute bottom-0 left-0 w-full h-16 pointer-events-none" aria-hidden="true">
</section>
{{ end }}
+18 -15
View File
@@ -1,30 +1,33 @@
{{ 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>
<dialog class="modal modal-open" onclick="if(event.target===this)this.remove()">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="this.closest('dialog').remove()">✕</button>
<h2>Manifest Details</h2>
<h2 class="text-xl font-semibold mb-4">Manifest Details</h2>
<div class="manifest-info">
<div class="info-row">
<strong>Digest:</strong>
<code>{{ .Digest }}</code>
<div class="space-y-3">
<div class="flex justify-between items-center">
<strong class="text-base-content/60 min-w-[150px]">Digest:</strong>
<code class="font-mono text-sm">{{ .Digest }}</code>
</div>
<div class="info-row">
<strong>Media Type:</strong>
<div class="flex justify-between items-center">
<strong class="text-base-content/60 min-w-[150px]">Media Type:</strong>
<span>{{ .MediaType }}</span>
</div>
<div class="info-row">
<strong>Hold Endpoint:</strong>
<div class="flex justify-between items-center">
<strong class="text-base-content/60 min-w-[150px]">Hold Endpoint:</strong>
<span>{{ .HoldEndpoint }}</span>
</div>
<div class="info-row">
<strong>Created:</strong>
<div class="flex justify-between items-center">
<strong class="text-base-content/60 min-w-[150px]">Created:</strong>
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }}
</time>
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="this.closest('dialog').remove()">close</button>
</form>
</dialog>
{{ end }}
@@ -1,5 +1,3 @@
{{ define "nav-brand" }}
<div class="nav-brand">
<a href="/"><span class="at-protocol">at://</span>Container Registry</a>
</div>
<a href="/" class="text-2xl font-bold text-neutral-content no-underline"><span class="text-primary">at://</span>Container Registry</a>
{{ end }}
@@ -1,10 +1,10 @@
{{ define "nav-search" }}
<div class="nav-search-wrapper">
<button id="search-toggle" onclick="toggleSearch()" class="btn-link search-toggle-btn" aria-label="Search">
<i data-lucide="search" class="search-icon"></i>
<button onclick="toggleSearch()" class="btn btn-ghost btn-circle" aria-label="Search">
<i data-lucide="search" class="size-5"></i>
</button>
<form action="/search" method="get" class="nav-search-form">
<input type="text" id="nav-search-input" name="q" placeholder="Search images..." value="{{ .Query }}" />
<input type="text" id="nav-search-input" name="q" placeholder="Search images..." value="{{ .Query }}" class="input input-sm input-bordered" />
</form>
</div>
{{ end }}
@@ -1,5 +1,30 @@
{{ define "nav-theme-toggle" }}
<button id="theme-toggle" onclick="toggleTheme()" class="btn-link theme-toggle-btn" aria-label="Toggle theme">
<i data-lucide="moon" class="theme-icon"></i>
</button>
<details class="dropdown dropdown-end">
<summary id="theme-toggle-btn" class="btn btn-ghost btn-circle list-none" aria-label="Theme settings">
<i data-lucide="sun" id="theme-icon" class="size-5"></i>
</summary>
<ul id="theme-dropdown-menu" class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
<li>
<button type="button" class="theme-option" data-value="system">
<i data-lucide="sun-moon" class="size-4"></i>
<span>System</span>
<i data-lucide="check" class="size-4 ml-auto text-primary theme-check invisible"></i>
</button>
</li>
<li>
<button type="button" class="theme-option" data-value="light">
<i data-lucide="sun" class="size-4"></i>
<span>Light</span>
<i data-lucide="check" class="size-4 ml-auto text-primary theme-check invisible"></i>
</button>
</li>
<li>
<button type="button" class="theme-option" data-value="dark">
<i data-lucide="moon" class="size-4"></i>
<span>Dark</span>
<i data-lucide="check" class="size-4 ml-auto text-primary theme-check invisible"></i>
</button>
</li>
</ul>
</details>
{{ end }}
+21 -18
View File
@@ -1,27 +1,30 @@
{{ define "nav-user" }}
{{ if .User }}
<div class="user-dropdown">
<button class="user-menu-btn" id="user-menu-btn" aria-expanded="false" aria-haspopup="true">
<details class="dropdown dropdown-end">
<summary class="btn btn-ghost gap-2 list-none" aria-label="User menu">
<div class="avatar{{ if not .User.Avatar }} avatar-placeholder{{ end }}">
{{ if .User.Avatar }}
<img src="{{ .User.Avatar }}" alt="{{ .User.Handle }}" class="user-avatar">
<div class="w-7 rounded-full">
<img src="{{ .User.Avatar }}" alt="{{ .User.Handle }}" />
</div>
{{ else }}
<div class="user-avatar-placeholder">{{ firstChar .User.Handle }}</div>
<div class="bg-neutral text-neutral-content w-7 rounded-full">
<span class="text-xs">{{ firstChar .User.Handle }}</span>
</div>
{{ end }}
<span class="user-handle">@{{ .User.Handle }}</span>
<svg class="dropdown-arrow" width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 9L1 4h10z"/>
</svg>
</button>
<div class="dropdown-menu" id="user-dropdown-menu" hidden>
<a href="/u/{{ .User.Handle }}" class="dropdown-item">Your Repositories</a>
<a href="/settings" class="dropdown-item">Settings</a>
<hr class="dropdown-divider">
<form action="/auth/logout" method="POST">
<button type="submit" class="dropdown-item logout-btn">Logout</button>
</form>
</div>
</div>
<span class="hidden sm:inline">@{{ .User.Handle }}</span>
<i data-lucide="chevron-down" class="size-3.5"></i>
</summary>
<ul class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg">
<li><a href="/u/{{ .User.Handle }}">Your Repositories</a></li>
<li><a href="/settings">Settings</a></li>
<li class="border-t border-base-300 mt-2 pt-2">
<a href="/auth/logout" class="text-error" onclick="event.preventDefault(); fetch('/auth/logout', {method: 'POST', credentials: 'same-origin'}).then(() => window.location.href = '/');">Logout</a>
</li>
</ul>
</details>
{{ else }}
<a href="/auth/oauth/login?return_to=/" class="btn-primary">Login</a>
<button type="button" onclick="window.location='/auth/oauth/login?return_to=/'" class="btn btn-primary btn-sm">Login</button>
{{ end }}
{{ end }}
+10 -6
View File
@@ -1,7 +1,9 @@
{{ define "nav" }}
<nav class="navbar">
{{ template "nav-brand" }}
<div class="nav-links">
<nav class="navbar bg-neutral text-neutral-content px-4">
<div class="navbar-start">
{{ template "nav-brand" }}
</div>
<div class="navbar-end flex items-center gap-2">
{{ template "nav-search" . }}
{{ template "nav-theme-toggle" }}
{{ template "nav-user" . }}
@@ -10,9 +12,11 @@
{{ end }}
{{ define "nav-simple" }}
<nav class="navbar">
{{ template "nav-brand" }}
<div class="nav-links">
<nav class="navbar bg-neutral text-neutral-content px-4">
<div class="navbar-start">
{{ template "nav-brand" }}
</div>
<div class="navbar-end flex items-center gap-2">
{{ template "nav-theme-toggle" }}
</div>
</nav>
@@ -0,0 +1,10 @@
{{ define "pull-count" }}
{{/*
Pull count component - displays download icon with count
Required: .PullCount (int)
*/}}
<span class="flex items-center gap-2 text-base-content/60">
<i data-lucide="arrow-down-to-line" class="size-[1.1rem] text-primary"></i>
<span class="font-semibold text-base-content">{{ .PullCount }}</span>
</span>
{{ end }}
+49 -25
View File
@@ -11,37 +11,61 @@
- StarCount: int - Number of stars
- PullCount: int - Number of pulls
- ArtifactType: string - container-image, helm-chart, unknown
- Tag: string (optional) - Latest tag name
- Digest: string (optional) - Latest manifest digest
- LastUpdated: time.Time (optional) - Last push time
*/}}
<a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="featured-card">
<div class="featured-header">
<div class="card card-border card-interactive bg-base-100 p-6 flex flex-col justify-between min-h-60 w-full" onclick="window.location='/r/{{ .OwnerHandle }}/{{ .Repository }}'">
<div class="flex gap-4 items-start">
{{ if .IconURL }}
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="featured-icon">
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="w-12 rounded-lg object-cover shrink-0">
{{ else }}
<div class="featured-icon-placeholder">{{ firstChar .Repository }}</div>
{{ end }}
<div class="featured-info">
<div class="featured-title">
<span class="featured-owner">{{ .OwnerHandle }}</span>
<span class="featured-separator">/</span>
<span class="featured-name">{{ .Repository }}</span>
{{ if eq .ArtifactType "helm-chart" }}
<span class="artifact-badge helm"><i data-lucide="anchor"></i></span>
{{ end }}
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content w-12 rounded-lg shadow-sm uppercase">
<span class="text-lg">{{ firstChar .Repository }}</span>
</div>
</div>
{{ if .Description }}
<p class="featured-description">{{ .Description }}</p>
{{ end }}
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm truncate">
<a href="/u/{{ .OwnerHandle }}" class="link link-primary" onclick="event.stopPropagation()">{{ .OwnerHandle }}</a>
<span class="text-base-content/60">/</span>
<a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="link text-base-content hover:underline" onclick="event.stopPropagation()">{{ .Repository }}</a>
</div>
{{ if .Tag }}
<span class="block text-base-content/60 text-sm truncate">Tag: {{ .Tag }}</span>
{{ end }}
</div>
</div>
<div class="featured-stats">
<span class="featured-stat">
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i>
<span class="stat-count">{{ .StarCount }}</span>
</span>
<span class="featured-stat">
<i data-lucide="arrow-down-to-line" class="pull-icon"></i>
<span class="stat-count">{{ .PullCount }}</span>
</span>
{{ if .Description }}
<p class="text-base-content/60 text-sm line-clamp-3 m-0 my-4">{{ .Description }}</p>
{{ end }}
<div class="flex-1 flex flex-col justify-end py-2 min-w-0">
{{ if eq .ArtifactType "helm-chart" }}
{{ if .Tag }}
{{ template "docker-command" (printf "helm pull oci://atcr.io/%s/%s --version %s" .OwnerHandle .Repository .Tag) }}
{{ else }}
{{ template "docker-command" (printf "helm pull oci://atcr.io/%s/%s" .OwnerHandle .Repository) }}
{{ end }}
{{ else }}
{{ if .Tag }}
{{ template "docker-command" (printf "docker pull atcr.io/%s/%s:%s" .OwnerHandle .Repository .Tag) }}
{{ else }}
{{ template "docker-command" (printf "docker pull atcr.io/%s/%s" .OwnerHandle .Repository) }}
{{ end }}
{{ end }}
</div>
</a>
<div class="flex justify-between items-center pt-3 border-t border-base-300">
<div class="flex gap-6 items-center">
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }}
{{ template "pull-count" (dict "PullCount" .PullCount) }}
{{ if eq .ArtifactType "helm-chart" }}
<span class="badge badge-sm badge-soft badge-primary" title="Helm chart"><i data-lucide="anchor"></i></span>
{{ end }}
</div>
{{ if not .LastUpdated.IsZero }}
<span class="text-base-content/60 text-sm">{{ timeAgo .LastUpdated }}</span>
{{ end }}
</div>
</div>
{{ end }}
@@ -0,0 +1,30 @@
{{ define "star" }}
{{/*
Star component - displays star icon with count
Required: .IsStarred (bool), .StarCount (int)
Optional: .Interactive (bool), .Handle (string), .Repository (string)
Interactive mode: renders as button with HTMX toggle
Display mode: renders as span (default)
*/}}
{{ if .Interactive }}
<button class="btn btn-sm gap-2{{ if .IsStarred }} btn-primary{{ else }} btn-ghost{{ end }}"
id="star-btn"
{{ if .IsStarred }}
hx-delete="/api/stars/{{ .Handle }}/{{ .Repository }}"
{{ else }}
hx-post="/api/stars/{{ .Handle }}/{{ .Repository }}"
{{ end }}
hx-swap="outerHTML"
hx-on::before-request="this.disabled=true"
hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'">
<i data-lucide="star" class="size-4 text-amber-400 stroke-amber-400{{ if .IsStarred }} fill-amber-400{{ end }}" id="star-icon"></i>
<span id="star-count">{{ .StarCount }}</span>
</button>
{{ else }}
<span class="flex items-center gap-2 text-base-content/60">
<i data-lucide="star" class="size-[1.1rem] text-amber-400 stroke-amber-400{{ if .IsStarred }} fill-amber-400{{ end }}"></i>
<span class="font-semibold text-base-content">{{ .StarCount }}</span>
</span>
{{ end }}
{{ end }}
+9 -7
View File
@@ -7,13 +7,15 @@
</head>
<body>
{{ template "nav-simple" . }}
<main class="error-page">
<div class="error-content">
<i data-lucide="anchor" class="error-icon"></i>
<div class="error-code">404</div>
<h1>Lost at Sea</h1>
<p>The page you're looking for has drifted into uncharted waters.</p>
<a href="/" class="btn btn-primary">Return to Port</a>
<main class="hero min-h-[60vh]">
<div class="hero-content text-center">
<div class="flex flex-col items-center">
<i data-lucide="anchor" class="size-16 text-neutral mb-4"></i>
<div class="font-bold text-primary" style="font-size: 150px; line-height: 1;">404</div>
<h1 class="text-2xl font-semibold mt-4">Lost at Sea</h1>
<p class="text-base-content/60 mt-2 max-w-md">The page you're looking for has drifted into uncharted waters.</p>
<a href="/" class="btn btn-primary mt-6">Return to Port</a>
</div>
</div>
</main>
<script>lucide.createIcons();</script>
+32 -61
View File
@@ -23,75 +23,46 @@
{{ template "nav" . }}
{{ if not .User }}
<!-- Hero Section for Non-Logged-In Users -->
<section class="hero-section">
<div class="hero-content">
<h1 class="hero-title">ship containers on the open web.</h1>
<p class="hero-subtitle">
Push and pull Docker images on the AT Protocol.<br>
Browse public registries or control your data.
</p>
<div class="hero-terminal">
<div class="terminal-header">
<span class="terminal-dot"></span>
<span class="terminal-dot"></span>
<span class="terminal-dot"></span>
</div>
<pre class="terminal-content"><span class="terminal-prompt">$</span> docker login atcr.io
<span class="terminal-prompt">$</span> docker push atcr.io/you/app
<span class="terminal-comment"># same docker, decentralized</span></pre>
</div>
<div class="hero-actions">
<a href="/auth/oauth/login?return_to=/" class="btn-hero-primary">Get Started</a>
<a href="/install" class="btn-hero-secondary">Learn More</a>
</div>
</div>
<!-- Benefit Cards -->
<div class="hero-benefits">
<div class="benefit-card">
<div class="benefit-icon"><i data-lucide="ship"></i></div>
<h3>Works with Docker</h3>
<p>Use docker push & pull. No new tools to learn.</p>
</div>
<div class="benefit-card">
<div class="benefit-icon"><i data-lucide="anchor"></i></div>
<h3>Your Data</h3>
<p>Join shared holds or captain your own storage.</p>
</div>
<div class="benefit-card">
<div class="benefit-icon"><i data-lucide="compass"></i></div>
<h3>Discover Images</h3>
<p>Browse and star public container registries.</p>
</div>
</div>
</section>
{{ template "hero" . }}
{{ end }}
<main class="container">
<div class="home-page">
<main class="container mx-auto px-4 py-8">
<div class="space-y-12">
<!-- Featured Repositories Section -->
{{ if .FeaturedRepos }}
<div class="featured-section">
<h1>Featured</h1>
<div class="featured-grid">
{{ range .FeaturedRepos }}
<section>
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">Featured</h2>
<div class="flex gap-2">
<button id="carousel-prev" class="btn btn-circle btn-ghost btn-sm">
<i data-lucide="chevron-left" class="size-5"></i>
</button>
<button id="carousel-next" class="btn btn-circle btn-ghost btn-sm">
<i data-lucide="chevron-right" class="size-5"></i>
</button>
</div>
</div>
<div id="featured-carousel" class="carousel w-full gap-6 scroll-smooth">
{{ range $i, $repo := .FeaturedRepos }}
<div id="featured-{{ $i }}" class="carousel-item overflow-hidden min-w-0 w-full md:w-[calc(50%-0.75rem)] lg:w-[calc(33.333%-1rem)] shrink-0">
{{ template "repo-card" $repo }}
</div>
{{ end }}
</div>
</section>
{{ end }}
<!-- Recently Updated Section -->
{{ if .RecentRepos }}
<section>
<h2 class="text-2xl font-bold mb-6">What's New</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{{ range .RecentRepos }}
{{ template "repo-card" . }}
{{ end }}
</div>
</div>
</section>
{{ end }}
<!-- Recent Pushes Section -->
<h1>What's New</h1>
<div id="push-list" hx-get="/api/recent-pushes" hx-trigger="load" hx-swap="innerHTML">
<!-- Initial loading state -->
<div class="loading">Loading recent pushes...</div>
</div>
</div>
</main>
+41 -28
View File
@@ -8,44 +8,57 @@
<body>
{{ template "nav-simple" . }}
<main class="container">
<div class="login-page">
<h1>Sign in to ATCR</h1>
<p>Use your ATProto handle to sign in</p>
<main class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4">
<div class="w-full max-w-md">
<h1 class="text-2xl font-semibold text-center mb-2">Sign in to ATCR</h1>
<p class="text-center text-base-content/60 mb-6">Use your ATProto handle to sign in</p>
{{ if .Error }}
<div class="error">
{{ if eq .Error "handle_required" }}
Please enter your handle
{{ else if eq .Error "auth_failed" }}
Authentication failed. Please try again.
{{ else }}
An error occurred. Please try again.
{{ end }}
<div class="alert alert-error mb-6">
<i data-lucide="circle-x" class="size-5"></i>
<span>
{{ if eq .Error "handle_required" }}
Please enter your handle
{{ else if eq .Error "auth_failed" }}
Authentication failed. Please try again.
{{ else }}
An error occurred. Please try again.
{{ end }}
</span>
</div>
{{ end }}
<form action="/auth/oauth/login" method="POST" class="login-form">
<form action="/auth/oauth/login" method="POST" id="login-form" class="card bg-base-100 p-6">
<input type="hidden" name="return_to" value="{{ .ReturnTo }}" />
<div class="form-group">
<label for="handle">Your ATProto Handle</label>
<input type="text"
id="handle"
name="handle"
placeholder="alice.bsky.social"
autocomplete="off"
required
autofocus />
<small>Enter your Bluesky or ATProto handle</small>
</div>
<fieldset class="fieldset relative">
<label class="label" for="handle">
<span class="label-text">Your ATProto Handle</span>
</label>
<actor-typeahead rows="5" class="block">
<input type="text"
id="handle"
name="handle"
class="input input-bordered w-full"
placeholder="alice.bsky.social"
autocomplete="off"
required
autofocus />
</actor-typeahead>
<p class="label">
<span class="label-text-alt text-base-content/60">Enter your Bluesky or ATProto handle</span>
</p>
</fieldset>
<button type="submit" class="btn-primary btn-large">Continue with ATProto</button>
<button type="submit" class="btn btn-primary w-full mt-4">
Continue with ATProto
</button>
</form>
<div class="login-help">
<p>Don't have an account? Create one at <a href="https://bsky.app" target="_blank">bsky.app</a></p>
</div>
<p class="text-center text-base-content/60 mt-6">
Don't have an account? Create one at
<a href="https://bsky.app" target="_blank" class="link link-primary">bsky.app</a>
</p>
</div>
</main>
</body>
+122 -217
View File
@@ -22,87 +22,89 @@
<body>
{{ template "nav" . }}
<main class="container">
<div class="repository-page">
<main class="container mx-auto px-4 py-8">
<div class="space-y-8">
<!-- Repository Header -->
<div class="repository-header">
<div class="repo-hero">
<div class="repo-hero-icon-wrapper">
<div class="card bg-base-100 shadow-sm p-6 space-y-6 w-full">
<div class="flex gap-4 items-start">
<div class="relative shrink-0">
{{ if .Repository.IconURL }}
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="w-20 rounded-lg object-cover">
{{ else }}
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content w-20 rounded-lg shadow-sm uppercase">
<span class="text-4xl">{{ firstChar .Repository.Name }}</span>
</div>
</div>
{{ end }}
{{ if $.IsOwner }}
<label class="avatar-upload-overlay" for="avatar-upload">
<i data-lucide="plus"></i>
<label class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity cursor-pointer rounded-lg" for="avatar-upload">
<i data-lucide="plus" class="size-8 text-white"></i>
</label>
<input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp"
onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden>
{{ end }}
</div>
<div class="repo-hero-info">
<h1>
<a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a>
<span class="repo-separator">/</span>
<span class="repo-name">{{ .Repository.Name }}</span>
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold">
<a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a>
<span class="text-base-content/60">/</span>
<span>{{ .Repository.Name }}</span>
</h1>
{{ if .Repository.Description }}
<p class="repo-hero-description">{{ .Repository.Description }}</p>
<p class="text-base-content/70 mt-2">{{ .Repository.Description }}</p>
{{ end }}
</div>
</div>
<!-- Star Button and Metadata Row -->
<div class="repo-info-row">
<div class="repo-actions">
<button class="star-btn{{ if .IsStarred }} starred{{ end }}" id="star-btn" onclick="toggleStar('{{ .Owner.Handle }}', '{{ .Repository.Name }}')">
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}" id="star-icon"></i>
<span class="star-count" id="star-count">{{ .StarCount }}</span>
</button>
<!-- Star Button, Pull Count and Metadata Row -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }}
{{ template "pull-count" (dict "PullCount" .PullCount) }}
</div>
<!-- Metadata Section -->
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
<div class="repo-metadata">
<div class="flex flex-wrap items-center gap-2">
{{ if .Repository.Version }}
<span class="metadata-badge version-badge" title="Version">
<span class="badge badge-md badge-primary badge-outline" title="Version">
{{ .Repository.Version }}
</span>
{{ end }}
{{ if .Repository.Licenses }}
{{ range parseLicenses .Repository.Licenses }}
{{ if .IsValid }}
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="badge badge-md badge-secondary" title="{{ .Name }}">
{{ .SPDXID }}
</a>
{{ else }}
<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
<span class="badge badge-md badge-secondary" title="Custom license: {{ .Name }}">
{{ .Name }}
</span>
{{ end }}
{{ end }}
{{ end }}
{{ if .Repository.SourceURL }}
<a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
<a href="{{ .Repository.SourceURL }}" target="_blank" class="link link-primary text-sm">
Source
</a>
{{ end }}
{{ if .Repository.DocumentationURL }}
<a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link">
<a href="{{ .Repository.DocumentationURL }}" target="_blank" class="link link-primary text-sm">
Documentation
</a>
{{ end }}
</div>
{{ else }}
<div class="repo-metadata"></div>
{{ end }}
</div>
<div class="divider my-2"></div>
<!-- Pull Command -->
<div class="pull-command-section">
<div class="space-y-2">
{{ if eq .ArtifactType "helm-chart" }}
<h3>Pull this chart</h3>
<h3 class="font-semibold">Pull this chart</h3>
{{ if .Tags }}
{{ $firstTag := index .Tags 0 }}
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " $firstTag.Tag.Tag) }}
@@ -110,7 +112,7 @@
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }}
{{ end }}
{{ else }}
<h3>Pull this image</h3>
<h3 class="font-semibold">Pull this image</h3>
{{ if .Tags }}
{{ $firstTag := index .Tags 0 }}
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }}
@@ -123,63 +125,63 @@
<!-- README and Tags/Manifests Layout -->
{{ if .ReadmeHTML }}
<div class="repo-content-layout">
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-8">
<!-- README Section (Left) -->
<div class="readme-section">
<h2>Overview</h2>
<div class="readme-content markdown-body">
<div class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
<h2 class="text-xl font-semibold">Overview</h2>
<div class="prose prose-sm max-w-none">
{{ .ReadmeHTML }}
</div>
</div>
<!-- Tags and Manifests (Right) -->
<div class="repo-sidebar">
<div class="space-y-8 min-w-0">
{{ end }}
<!-- Tags Section -->
<div class="repo-section">
<h2>Tags</h2>
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Tags</h2>
{{ if .Tags }}
<div class="tags-list">
<div class="space-y-4">
{{ range .Tags }}
<div class="tag-item" id="tag-{{ sanitizeID .Tag.Tag }}">
<div class="tag-item-header">
<div>
<span class="tag-name-large">{{ .Tag.Tag }}</span>
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="tag-{{ sanitizeID .Tag.Tag }}">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono font-semibold text-lg">{{ .Tag.Tag }}</span>
{{ if eq .ArtifactType "helm-chart" }}
<span class="badge-helm"><i data-lucide="anchor"></i> Helm</span>
<span class="badge badge-md badge-soft badge-primary"><i data-lucide="anchor" class="size-3"></i> Helm</span>
{{ else if .IsMultiArch }}
<span class="badge-multi">Multi-arch</span>
<span class="badge badge-md badge-primary">Multi-arch</span>
{{ end }}
{{ if .HasAttestations }}
<span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</span>
<span class="badge badge-md badge-success"><i data-lucide="shield-check" class="size-3"></i> Attestations</span>
{{ end }}
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<time class="tag-timestamp" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
<div class="flex items-center gap-2">
<time class="text-sm text-base-content/60" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .Tag.CreatedAt }}
</time>
{{ if $.IsOwner }}
<button class="delete-btn"
<button class="btn btn-ghost btn-sm text-error"
hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag.Tag }}"
hx-confirm="Delete tag {{ .Tag.Tag }}?"
hx-target="#tag-{{ sanitizeID .Tag.Tag }}"
hx-swap="outerHTML">
<i data-lucide="trash-2"></i>
<i data-lucide="trash-2" class="size-4"></i>
</button>
{{ end }}
</div>
</div>
<div class="tag-item-details">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div class="digest-container">
<code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')"><i data-lucide="copy"></i></button>
<div class="text-sm">
<div class="flex flex-wrap justify-between items-center gap-2">
<div class="flex items-center gap-2">
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Tag.Digest }}')"><i data-lucide="copy" class="size-3"></i></button>
</div>
{{ if .Platforms }}
<div class="platforms-inline">
<div class="flex flex-wrap gap-1">
{{ range .Platforms }}
<span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
<span class="badge badge-sm badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
{{ end }}
@@ -194,77 +196,79 @@
{{ end }}
</div>
{{ else }}
<p class="empty-message">No tags available</p>
<p class="text-base-content/60">No tags available</p>
{{ end }}
</div>
<!-- Manifests Section -->
<div class="repo-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2>Manifests</h2>
<label class="show-offline-toggle">
<input type="checkbox" id="show-offline-toggle" onchange="toggleOfflineManifests()">
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
<div class="flex flex-wrap justify-between items-center gap-4">
<h2 class="text-xl font-semibold">Manifests</h2>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" id="show-offline-toggle" onchange="toggleOfflineManifests()">
<span>Show offline images</span>
</label>
</div>
{{ if .Manifests }}
<div class="manifests-list">
<div class="space-y-4">
{{ range .Manifests }}
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
<div class="manifest-item-header">
<div>
{{ if .IsManifestList }}
<span class="manifest-type"><i data-lucide="package"></i> Multi-arch</span>
{{ else if eq .ArtifactType "helm-chart" }}
<span class="manifest-type helm"><i data-lucide="anchor"></i> Helm Chart</span>
{{ else }}
<span class="manifest-type"><i data-lucide="box"></i> Image</span>
{{ end }}
{{ if .HasAttestations }}
<span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</span>
{{ end }}
{{ if .Pending }}
<span class="checking-badge"
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
<i data-lucide="refresh-ccw"></i> Checking...
</span>
{{ else if not .Reachable }}
<span class="offline-badge"><i data-lucide="alert-triangle"></i> Offline</span>
{{ end }}
<div class="digest-container">
<code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')"><i data-lucide="copy"></i></button>
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
{{ if .IsManifestList }}
<span class="flex items-center gap-1 font-medium"><i data-lucide="package" class="size-4"></i> Multi-arch</span>
{{ else if eq .ArtifactType "helm-chart" }}
<span class="flex items-center gap-1 font-medium text-primary"><i data-lucide="anchor" class="size-4"></i> Helm Chart</span>
{{ else }}
<span class="flex items-center gap-1 font-medium"><i data-lucide="box" class="size-4"></i> Image</span>
{{ end }}
{{ if .HasAttestations }}
<span class="badge badge-md badge-success"><i data-lucide="shield-check" class="size-3"></i> Attestations</span>
{{ end }}
{{ if .Pending }}
<span class="badge badge-sm badge-info"
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
<i data-lucide="refresh-ccw" class="size-3"></i> Checking...
</span>
{{ else if not .Reachable }}
<span class="badge badge-sm badge-warning"><i data-lucide="alert-triangle" class="size-3"></i> Offline</span>
{{ end }}
</div>
<div class="flex items-center gap-2">
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')"><i data-lucide="copy" class="size-3"></i></button>
</div>
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
<div class="flex items-center gap-2">
<time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .Manifest.CreatedAt }}
</time>
{{ if $.IsOwner }}
<button class="delete-btn"
<button class="btn btn-ghost btn-sm text-error"
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')">
<i data-lucide="trash-2"></i>
<i data-lucide="trash-2" class="size-4"></i>
</button>
{{ end }}
</div>
</div>
<div class="manifest-item-details">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div class="text-sm">
<div class="flex flex-wrap justify-between items-center gap-2">
<div>
{{ if .Tags }}
<span class="manifest-detail-label">Tags:</span>
<span class="text-base-content/60">Tags:</span>
{{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }}
{{ else }}
<span class="text-muted">(untagged)</span>
<span class="text-base-content/50">(untagged)</span>
{{ end }}
</div>
{{ if .IsManifestList }}
{{ if .Platforms }}
<div class="platforms-inline">
<div class="flex flex-wrap gap-1">
{{ range .Platforms }}
<span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
<span class="badge badge-sm badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
{{ end }}
@@ -275,13 +279,13 @@
{{ end }}
</div>
{{ else }}
<p class="empty-message">No manifests available</p>
<p class="text-base-content/60">No manifests available</p>
{{ end }}
</div>
{{ if .ReadmeHTML }}
</div><!-- Close repo-sidebar -->
</div><!-- Close repo-content-layout -->
</div><!-- Close sidebar -->
</div><!-- Close grid layout -->
{{ end }}
</div>
</main>
@@ -290,121 +294,22 @@
<div id="modal"></div>
<!-- Manifest Delete Confirmation Modal -->
<div id="manifest-delete-modal" class="modal-overlay" style="display: none;">
<div class="modal-dialog">
<div class="modal-header">
<h3>Confirm Deletion</h3>
<button class="modal-close" onclick="closeManifestDeleteModal()">&times;</button>
</div>
<div class="modal-body">
<p id="manifest-delete-message">This manifest has associated tags that will also be deleted:</p>
<ul id="manifest-delete-tags" class="tag-list"></ul>
<p><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeManifestDeleteModal()">Cancel</button>
<button class="btn btn-danger" id="confirm-manifest-delete-btn">Delete All</button>
<dialog id="manifest-delete-modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Confirm Deletion</h3>
<p id="manifest-delete-message" class="py-2">This manifest has associated tags that will also be deleted:</p>
<ul id="manifest-delete-tags" class="list-disc list-inside text-sm space-y-1"></ul>
<p class="font-bold py-2 text-error">This action cannot be undone.</p>
<div class="modal-action">
<button class="btn" onclick="closeManifestDeleteModal()">Cancel</button>
<button class="btn btn-error" id="confirm-manifest-delete-btn">Delete All</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="closeManifestDeleteModal()">close</button>
</form>
</dialog>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #333);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color, #fff);
padding: 0;
width: 2rem;
height: 2rem;
line-height: 1;
}
.modal-body {
padding: 1.5rem;
}
.modal-body .tag-list {
margin: 1rem 0;
padding-left: 1.5rem;
}
.modal-body .tag-list li {
margin: 0.5rem 0;
font-family: monospace;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color, #333);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.btn-secondary {
background: var(--bg-tertiary, #2a2a2a);
color: var(--text-color, #fff);
}
.btn-secondary:hover {
background: var(--bg-hover, #3a3a3a);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
</style>
</body>
</html>
{{ end }}
File diff suppressed because it is too large Load Diff
+24 -10
View File
@@ -22,31 +22,45 @@
<body>
{{ template "nav" . }}
<main class="container">
<div class="home-page">
<div class="user-profile">
<main class="container mx-auto px-4 py-8">
<div class="flex flex-col items-center gap-8">
<!-- User Profile Header -->
<div class="flex flex-col items-center gap-4">
{{ if .ViewedUser.Avatar }}
<img src="{{ .ViewedUser.Avatar }}" alt="{{ .ViewedUser.Handle }}" class="profile-avatar">
<div class="avatar">
<div class="w-20 rounded-full shadow">
<img src="{{ .ViewedUser.Avatar }}" alt="{{ .ViewedUser.Handle }}" />
</div>
</div>
{{ else if .HasProfile }}
<div class="profile-avatar-placeholder">{{ firstChar .ViewedUser.Handle }}</div>
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content w-20 rounded-full shadow">
<span class="text-3xl">{{ firstChar .ViewedUser.Handle }}</span>
</div>
</div>
{{ else }}
<div class="profile-avatar-placeholder">?</div>
<div class="avatar avatar-placeholder">
<div class="bg-base-300 text-base-content/60 w-20 rounded-full shadow">
<span class="text-3xl">?</span>
</div>
</div>
{{ end }}
<h1>{{ .ViewedUser.Handle }}</h1>
<h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1>
</div>
<!-- Content -->
{{ if not .HasProfile }}
<div class="empty-state">
<div class="text-center text-base-content/60 py-12">
<p>This user hasn't set up their ATCR profile yet.</p>
</div>
{{ else if .Repositories }}
<div class="featured-grid">
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{{ range .Repositories }}
{{ template "repo-card" . }}
{{ end }}
</div>
{{ else }}
<div class="empty-state">
<div class="text-center text-base-content/60 py-12">
<p>No images yet.</p>
</div>
{{ end }}
@@ -1,10 +1,10 @@
{{ define "health-badge" }}
{{ if .Pending }}
<span class="checking-badge"
<span class="badge badge-sm badge-info"
hx-get="/api/manifest-health?endpoint={{ .RetryURL }}"
hx-trigger="load delay:3s"
hx-swap="outerHTML"><i data-lucide="refresh-ccw"></i> Checking...</span>
{{ else if not .Reachable }}
<span class="offline-badge"><i data-lucide="triangle-alert"></i> Offline</span>
<span class="badge badge-sm badge-warning"><i data-lucide="triangle-alert"></i> Offline</span>
{{ end }}
{{ end }}
+29 -31
View File
@@ -1,46 +1,44 @@
{{ range .Pushes }}
<div class="push-card">
<div class="push-header">
<div class="card p-4">
<div class="flex items-start gap-4">
{{ if .IconURL }}
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="push-icon">
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="size-12 rounded-lg object-cover shrink-0">
{{ else }}
<div class="push-icon-placeholder">{{ firstChar .Repository }}</div>
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content size-12 rounded-lg shadow-sm">
<span class="text-lg">{{ firstChar .Repository }}</span>
</div>
</div>
{{ end }}
<div class="push-info">
<div class="push-title-row">
<div class="push-title">
<a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a>
<span class="push-separator">/</span>
<a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a>
<span class="push-separator">:</span>
<span class="push-tag">{{ .Tag }}</span>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center gap-4">
<div class="truncate">
<a href="/u/{{ .Handle }}" class="link link-primary font-medium">{{ .Handle }}</a>
<span class="text-base-content/60">/</span>
<a href="/r/{{ .Handle }}/{{ .Repository }}" class="link text-base-content font-medium hover:underline">{{ .Repository }}</a>
<span class="text-base-content/60 mx-1">:</span>
<span class="text-base-content/60">{{ .Tag }}</span>
{{ if eq .ArtifactType "helm-chart" }}
<span class="artifact-badge helm"><i data-lucide="anchor"></i></span>
<span class="badge badge-xs badge-soft badge-primary"><i data-lucide="anchor"></i></span>
{{ end }}
</div>
<div class="push-stats">
<span class="push-stat">
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i>
<span class="stat-count">{{ .StarCount }}</span>
</span>
<span class="push-stat">
<i data-lucide="arrow-down-to-line" class="pull-icon"></i>
<span class="stat-count">{{ .PullCount }}</span>
</span>
<div class="flex items-center gap-4 shrink-0">
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }}
{{ template "pull-count" (dict "PullCount" .PullCount) }}
</div>
</div>
{{ if .Description }}
<p class="push-description">{{ .Description }}</p>
<p class="text-base-content/60 text-sm mt-1 m-0">{{ .Description }}</p>
{{ end }}
</div>
</div>
<div class="push-details">
<div class="digest-container">
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')"><i data-lucide="copy"></i></button>
<div class="flex items-center gap-4 mt-3 pt-3 border-t border-base-300 text-base-content/60">
<div class="flex items-center gap-2">
<code class="font-mono text-sm truncate max-w-[200px]" title="{{ .Digest }}">{{ .Digest }}</code>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')"><i data-lucide="copy" class="size-4"></i></button>
</div>
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}" class="text-sm">
{{ timeAgo .CreatedAt }}
</time>
</div>
@@ -48,8 +46,8 @@
{{ end }}
{{ if eq (len .Pushes) 0 }}
<div class="empty-state">
<p>No pushes yet. Start using ATCR by pushing your first image!</p>
<pre><code>docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></pre>
<div class="py-8 text-center">
<p class="text-base-content/60">No pushes yet. Start using ATCR by pushing your first image!</p>
<pre class="mt-4"><code class="font-mono text-sm">docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></pre>
</div>
{{ end }}
@@ -1,32 +1,30 @@
{{ define "storage_stats" }}
<div class="storage-stats">
<div class="space-y-2">
{{ if .Tier }}
<div class="stat-row">
<span class="stat-label">Tier:</span>
<span class="stat-value tier-badge tier-{{ .Tier }}">{{ .Tier }}</span>
<div class="flex justify-between items-center">
<span class="text-base-content/60">Tier:</span>
<span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span>
</div>
{{ end }}
<div class="stat-row">
<span class="stat-label">Storage:</span>
<span class="stat-value">
<div class="flex justify-between items-center">
<span class="text-base-content/60">Storage:</span>
<span class="font-semibold font-mono">
{{ if .HasLimit }}
{{ .HumanSize }} / {{ .HumanLimit }}
{{ else }}
{{ .HumanSize }} <span class="unlimited-badge">Unlimited</span>
{{ .HumanSize }} <span class="badge badge-xs badge-success">Unlimited</span>
{{ end }}
</span>
</div>
{{ if .HasLimit }}
<div class="quota-progress">
<div class="progress-bar">
<div class="progress-fill {{ if ge .UsagePercent 95 }}progress-danger{{ else if ge .UsagePercent 80 }}progress-warning{{ else }}progress-ok{{ end }}" style="width: {{ .UsagePercent }}%"></div>
</div>
<span class="progress-text">{{ .UsagePercent }}% used</span>
<div class="flex items-center gap-2 py-2">
<progress class="progress {{ if ge .UsagePercent 95 }}progress-error{{ else if ge .UsagePercent 80 }}progress-warning{{ else }}progress-success{{ end }} w-full" value="{{ .UsagePercent }}" max="100"></progress>
<span class="text-sm text-base-content/60 whitespace-nowrap">{{ .UsagePercent }}% used</span>
</div>
{{ end }}
<div class="stat-row">
<span class="stat-label">Unique Blobs:</span>
<span class="stat-value">{{ .UniqueBlobs }}</span>
<div class="flex justify-between items-center">
<span class="text-base-content/60">Unique Blobs:</span>
<span class="font-semibold font-mono">{{ .UniqueBlobs }}</span>
</div>
</div>
{{ end }}
+21 -13
View File
@@ -12,14 +12,13 @@ import (
"atcr.io/pkg/appview/licenses"
)
//go:generate curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
//go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
//go:generate sh -c "cd ../.. && npm run build"
//go:embed templates/**/*.html
var templatesFS embed.FS
//go:embed static
var staticFS embed.FS
//go:embed public
var publicFS embed.FS
// Templates returns parsed templates with helper functions
func Templates() (*template.Template, error) {
@@ -96,6 +95,15 @@ func Templates() (*template.Template, error) {
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
return licenses.ParseLicenses(licensesStr)
},
"dict": func(values ...any) map[string]any {
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, _ := values[i].(string)
dict[key] = values[i+1]
}
return dict
},
}
tmpl := template.New("").Funcs(funcMap)
@@ -107,18 +115,18 @@ func Templates() (*template.Template, error) {
return tmpl, nil
}
// StaticHandler returns HTTP handler for static files
func StaticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
// PublicHandler returns HTTP handler for static files
func PublicHandler() http.Handler {
sub, err := fs.Sub(publicFS, "public")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}
// StaticRootFiles returns list of root-level files in static directory (not subdirectories)
func StaticRootFiles() ([]string, error) {
entries, err := staticFS.ReadDir("static")
// PublicRootFiles returns list of root-level files in static directory (not subdirectories)
func PublicRootFiles() ([]string, error) {
entries, err := publicFS.ReadDir("public")
if err != nil {
return nil, err
}
@@ -133,9 +141,9 @@ func StaticRootFiles() ([]string, error) {
return files, nil
}
// StaticSubdir returns an fs.FS for a subdirectory within static/
func StaticSubdir(name string) http.Handler {
sub, err := fs.Sub(staticFS, "static/"+name)
// PublicSubdir returns an fs.FS for a subdirectory within static/
func PublicSubdir(name string) http.Handler {
sub, err := fs.Sub(publicFS, "public/"+name)
if err != nil {
panic(err)
}
+15 -9
View File
@@ -581,6 +581,9 @@ func TestTemplateExecution_RepoCard(t *testing.T) {
PullCount int
IsStarred bool
ArtifactType string
Tag string
Digest string
LastUpdated time.Time
}{
OwnerHandle: "alice.bsky.social",
Repository: "myapp",
@@ -590,6 +593,9 @@ func TestTemplateExecution_RepoCard(t *testing.T) {
PullCount: 1337,
IsStarred: true,
ArtifactType: "container-image",
Tag: "latest",
Digest: "sha256:abc123def456",
LastUpdated: time.Now().Add(-24 * time.Hour),
}
buf := new(bytes.Buffer)
@@ -605,9 +611,9 @@ func TestTemplateExecution_RepoCard(t *testing.T) {
"alice.bsky.social",
"myapp",
"A cool container image",
"42", // star count
"1337", // pull count
"featured-icon-placeholder", // no icon URL provided
"42", // star count
"1337", // pull count
"avatar-placeholder", // DaisyUI avatar placeholder when no icon URL
}
for _, expected := range expectedContent {
@@ -704,8 +710,8 @@ func TestTemplateExecution_HealthBadge(t *testing.T) {
"Reachable": false,
"RetryURL": "http%3A%2F%2Fexample.com",
},
expectInOutput: "checking-badge",
expectMissing: "offline-badge",
expectInOutput: "badge-info",
expectMissing: "badge-warning",
},
{
name: "offline state",
@@ -714,8 +720,8 @@ func TestTemplateExecution_HealthBadge(t *testing.T) {
"Reachable": false,
"RetryURL": "",
},
expectInOutput: "offline-badge",
expectMissing: "checking-badge",
expectInOutput: "badge-warning",
expectMissing: "badge-info",
},
{
name: "online state - empty output",
@@ -774,8 +780,8 @@ func TestTemplateExecution_Alert(t *testing.T) {
}
}
func TestStaticHandler(t *testing.T) {
handler := StaticHandler()
func TestPublicHandler(t *testing.T) {
handler := PublicHandler()
if handler == nil {
t.Fatal("StaticHandler() returned nil")
}
+12
View File
@@ -256,6 +256,18 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
})
// Set a JS-readable cookie with the handle for "recent accounts" feature
// Frontend will read this, save to localStorage, and delete the cookie
http.SetCookie(w, &http.Cookie{
Name: "atcr_login_handle",
Value: handle,
Path: "/",
MaxAge: 60, // Short-lived, just for the redirect
HttpOnly: false,
Secure: r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
// Redirect to return URL
returnTo := cookie.Value
if returnTo == "" {
+6 -6
View File
@@ -3,8 +3,8 @@
// and usage metrics. The admin panel is embedded directly in the hold service binary.
package admin
//go:generate curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
//go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
//go:generate curl -fsSL -o public/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
//go:generate curl -fsSL -o public/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
import (
"context"
@@ -33,8 +33,8 @@ import (
//go:embed templates/*
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
//go:embed public/*
var publicFS embed.FS
// AdminConfig holds admin panel configuration
type AdminConfig struct {
@@ -291,8 +291,8 @@ func isIPAddress(host string) bool {
// RegisterRoutes registers all admin routes with the router
func (ui *AdminUI) RegisterRoutes(r chi.Router) {
// Static files (public)
staticSub, _ := fs.Sub(staticFS, "static")
r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", http.FileServer(http.FS(staticSub))))
staticSub, _ := fs.Sub(publicFS, "public")
r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", http.FileServer(http.FS(staticSub))))
// OAuth client metadata endpoint (required for production OAuth)
r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata)
+3 -3
View File
@@ -5,9 +5,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Hold Admin</title>
<script src="/admin/static/js/htmx.min.js"></script>
<script src="/admin/static/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<script src="/admin/public/js/htmx.min.js"></script>
<script src="/admin/public/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body>
{{template "nav" .}}
+3 -3
View File
@@ -5,9 +5,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Hold Admin</title>
<script src="/admin/static/js/htmx.min.js"></script>
<script src="/admin/static/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<script src="/admin/public/js/htmx.min.js"></script>
<script src="/admin/public/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body>
{{template "nav" .}}
@@ -5,9 +5,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Hold Admin</title>
<script src="/admin/static/js/htmx.min.js"></script>
<script src="/admin/static/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<script src="/admin/public/js/htmx.min.js"></script>
<script src="/admin/public/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body>
{{template "nav" .}}
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Hold Admin</title>
<script src="/admin/static/js/htmx.min.js"></script>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<script src="/admin/public/js/htmx.min.js"></script>
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body>
{{template "nav" .}}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Hold Admin</title>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body>
{{template "nav" .}}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Hold Admin</title>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body class="login-page">
<div class="login-container">
+3 -3
View File
@@ -5,9 +5,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Hold Admin</title>
<script src="/admin/static/js/htmx.min.js"></script>
<script src="/admin/static/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/static/css/admin.css">
<script src="/admin/public/js/htmx.min.js"></script>
<script src="/admin/public/js/lucide.min.js"></script>
<link rel="stylesheet" href="/admin/public/css/admin.css">
</head>
<body>
{{template "nav" .}}
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pkg/appview/templates/**/*.html",
"./pkg/appview/public/js/**/*.js",
],
// DaisyUI handles dark mode via data-theme
theme: {
extend: {
// Only keep custom extensions not covered by DaisyUI
colors: {
star: 'var(--star)',
},
fontFamily: {
mono: ['Monaco', 'Menlo', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'],
},
},
},
// DaisyUI is added via @plugin in CSS
}