mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-27 12:44:16 +00:00
begin large refactor of UI to use tailwind and daisy
This commit is contained in:
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated files
|
||||
pkg/appview/public/css/style.css
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
Generated
+1573
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Vendored
+139
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 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()">×</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
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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" .}}
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user