A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

Select the types of activity you want to include in your feed.

add user page, clean up UI. begin label spec implementation

+449 -44
+10
Dockerfile
··· 43 43 # Set environment variables 44 44 ENV ATCR_CONFIG=/etc/atcr/config.yml 45 45 46 + # OCI image annotations 47 + LABEL org.opencontainers.image.title="ATCR Registry" \ 48 + org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ 49 + org.opencontainers.image.authors="ATCR Contributors" \ 50 + org.opencontainers.image.source="https://github.com/example/atcr" \ 51 + org.opencontainers.image.documentation="https://atcr.io/docs" \ 52 + org.opencontainers.image.licenses="MIT" \ 53 + org.opencontainers.image.version="0.1.0" \ 54 + io.atcr.icon="https://atcr.io/images/registry-icon.png" 55 + 46 56 # Run the registry 47 57 ENTRYPOINT ["/app/atcr-registry"] 48 58 CMD ["serve", "/etc/atcr/config.yml"]
+10
Dockerfile.hold
··· 32 32 # Expose default port 33 33 EXPOSE 8080 34 34 35 + # OCI image annotations 36 + LABEL org.opencontainers.image.title="ATCR Hold Service" \ 37 + org.opencontainers.image.description="ATCR Hold Service - Bring Your Own Storage component for ATCR" \ 38 + org.opencontainers.image.authors="ATCR Contributors" \ 39 + org.opencontainers.image.source="https://github.com/example/atcr" \ 40 + org.opencontainers.image.documentation="https://atcr.io/docs/hold" \ 41 + org.opencontainers.image.licenses="MIT" \ 42 + org.opencontainers.image.version="0.1.0" \ 43 + io.atcr.icon="https://atcr.io/images/hold-icon.png" 44 + 35 45 # Run the hold service 36 46 ENTRYPOINT ["./atcr-hold"] 37 47 CMD ["/etc/atcr/hold.yml"]
+8
cmd/registry/serve.go
··· 433 433 }, 434 434 )).Methods("GET") 435 435 436 + router.Handle("/u/{handle}", appmiddleware.OptionalAuth(sessionStore, database)( 437 + &uihandlers.UserPageHandler{ 438 + DB: database, 439 + Templates: templates, 440 + RegistryURL: uihandlers.TrimRegistryURL(baseURL), 441 + }, 442 + )).Methods("GET") 443 + 436 444 // Authenticated routes 437 445 authRouter := router.NewRoute().Subrouter() 438 446 authRouter.Use(appmiddleware.RequireAuth(sessionStore, database))
+29 -17
pkg/appview/db/models.go
··· 13 13 14 14 // Manifest represents an OCI manifest stored in the cache 15 15 type Manifest struct { 16 - ID int64 17 - DID string 18 - Repository string 19 - Digest string 20 - HoldEndpoint string 21 - SchemaVersion int 22 - MediaType string 23 - ConfigDigest string 24 - ConfigSize int64 25 - RawManifest string // JSON 26 - CreatedAt time.Time 16 + ID int64 17 + DID string 18 + Repository string 19 + Digest string 20 + HoldEndpoint string 21 + SchemaVersion int 22 + MediaType string 23 + ConfigDigest string 24 + ConfigSize int64 25 + RawManifest string // JSON 26 + CreatedAt time.Time 27 + Title string 28 + Description string 29 + SourceURL string 30 + DocumentationURL string 31 + Licenses string 32 + IconURL string 27 33 } 28 34 29 35 // Layer represents a layer in a manifest ··· 58 64 59 65 // Repository represents an aggregated view of a user's repository 60 66 type Repository struct { 61 - Name string 62 - TagCount int 63 - ManifestCount int 64 - LastPush time.Time 65 - Tags []Tag 66 - Manifests []Manifest 67 + Name string 68 + TagCount int 69 + ManifestCount int 70 + LastPush time.Time 71 + Tags []Tag 72 + Manifests []Manifest 73 + Title string 74 + Description string 75 + SourceURL string 76 + DocumentationURL string 77 + Licenses string 78 + IconURL string 67 79 }
+93 -7
pkg/appview/db/queries.go
··· 135 135 // Get manifests for this repo 136 136 manifestRows, err := db.Query(` 137 137 SELECT id, digest, hold_endpoint, schema_version, media_type, 138 - config_digest, config_size, raw_manifest, created_at 138 + config_digest, config_size, raw_manifest, created_at, 139 + title, description, source_url, documentation_url, licenses, icon_url 139 140 FROM manifests 140 141 WHERE did = ? AND repository = ? 141 142 ORDER BY created_at DESC ··· 149 150 var m Manifest 150 151 m.DID = did 151 152 m.Repository = r.Name 153 + 154 + // Use sql.NullString for nullable annotation fields 155 + var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 156 + 152 157 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 153 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt); err != nil { 158 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt, 159 + &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { 154 160 manifestRows.Close() 155 161 return nil, err 156 162 } 163 + 164 + // Convert NullString to string 165 + if title.Valid { 166 + m.Title = title.String 167 + } 168 + if description.Valid { 169 + m.Description = description.String 170 + } 171 + if sourceURL.Valid { 172 + m.SourceURL = sourceURL.String 173 + } 174 + if documentationURL.Valid { 175 + m.DocumentationURL = documentationURL.String 176 + } 177 + if licenses.Valid { 178 + m.Licenses = licenses.String 179 + } 180 + if iconURL.Valid { 181 + m.IconURL = iconURL.String 182 + } 183 + 157 184 r.Manifests = append(r.Manifests, m) 158 185 } 159 186 manifestRows.Close() 160 187 188 + // Aggregate repository-level annotations from most recent manifest 189 + if len(r.Manifests) > 0 { 190 + latest := r.Manifests[0] 191 + r.Title = latest.Title 192 + r.Description = latest.Description 193 + r.SourceURL = latest.SourceURL 194 + r.DocumentationURL = latest.DocumentationURL 195 + r.Licenses = latest.Licenses 196 + r.IconURL = latest.IconURL 197 + } 198 + 161 199 repos = append(repos, r) 162 200 } 163 201 ··· 183 221 return &user, nil 184 222 } 185 223 224 + // GetUserByHandle retrieves a user by handle 225 + func GetUserByHandle(db *sql.DB, handle string) (*User, error) { 226 + var user User 227 + err := db.QueryRow(` 228 + SELECT did, handle, pds_endpoint, avatar, last_seen 229 + FROM users 230 + WHERE handle = ? 231 + `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.Avatar, &user.LastSeen) 232 + 233 + if err == sql.ErrNoRows { 234 + return nil, nil 235 + } 236 + if err != nil { 237 + return nil, err 238 + } 239 + 240 + return &user, nil 241 + } 242 + 186 243 // UpsertUser inserts or updates a user record 187 244 func UpsertUser(db *sql.DB, user *User) error { 188 245 _, err := db.Exec(` ··· 327 384 result, err := db.Exec(` 328 385 INSERT OR IGNORE INTO manifests 329 386 (did, repository, digest, hold_endpoint, schema_version, media_type, 330 - config_digest, config_size, raw_manifest, created_at) 331 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 387 + config_digest, config_size, raw_manifest, created_at, 388 + title, description, source_url, documentation_url, licenses, icon_url) 389 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 332 390 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 333 391 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 334 - manifest.ConfigSize, manifest.RawManifest, manifest.CreatedAt) 392 + manifest.ConfigSize, manifest.RawManifest, manifest.CreatedAt, 393 + manifest.Title, manifest.Description, manifest.SourceURL, 394 + manifest.DocumentationURL, manifest.Licenses, manifest.IconURL) 335 395 336 396 if err != nil { 337 397 return 0, err ··· 380 440 // GetManifest fetches a single manifest by digest 381 441 func GetManifest(db *sql.DB, digest string) (*Manifest, error) { 382 442 var m Manifest 443 + 444 + // Use sql.NullString for nullable annotation fields 445 + var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 446 + 383 447 err := db.QueryRow(` 384 448 SELECT id, did, repository, digest, hold_endpoint, schema_version, 385 - media_type, config_digest, config_size, raw_manifest, created_at 449 + media_type, config_digest, config_size, raw_manifest, created_at, 450 + title, description, source_url, documentation_url, licenses, icon_url 386 451 FROM manifests 387 452 WHERE digest = ? 388 453 `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint, 389 454 &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, 390 - &m.RawManifest, &m.CreatedAt) 455 + &m.RawManifest, &m.CreatedAt, 456 + &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL) 391 457 392 458 if err != nil { 393 459 return nil, err 460 + } 461 + 462 + // Convert NullString to string 463 + if title.Valid { 464 + m.Title = title.String 465 + } 466 + if description.Valid { 467 + m.Description = description.String 468 + } 469 + if sourceURL.Valid { 470 + m.SourceURL = sourceURL.String 471 + } 472 + if documentationURL.Valid { 473 + m.DocumentationURL = documentationURL.String 474 + } 475 + if licenses.Valid { 476 + m.Licenses = licenses.String 477 + } 478 + if iconURL.Valid { 479 + m.IconURL = iconURL.String 394 480 } 395 481 396 482 return &m, nil
+24
pkg/appview/db/schema.go
··· 30 30 config_size INTEGER, 31 31 raw_manifest TEXT NOT NULL, 32 32 created_at TIMESTAMP NOT NULL, 33 + title TEXT, 34 + description TEXT, 35 + source_url TEXT, 36 + documentation_url TEXT, 37 + licenses TEXT, 38 + icon_url TEXT, 33 39 UNIQUE(did, repository, digest), 34 40 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 35 41 ); ··· 103 109 if err := migrateCDNURLs(db); err != nil { 104 110 // Log but don't fail - not critical 105 111 println("Warning: Failed to migrate CDN URLs:", err.Error()) 112 + } 113 + 114 + // Migration: Add OCI annotation columns to manifests table 115 + annotationColumns := []string{ 116 + "title TEXT", 117 + "description TEXT", 118 + "source_url TEXT", 119 + "documentation_url TEXT", 120 + "licenses TEXT", 121 + "icon_url TEXT", 122 + } 123 + for _, col := range annotationColumns { 124 + colName := strings.Split(col, " ")[0] 125 + _, err = db.Exec(`ALTER TABLE manifests ADD COLUMN ` + col) 126 + if err != nil && !strings.Contains(err.Error(), "duplicate column") { 127 + // Log but continue - column might already exist 128 + println("Warning: Failed to add column", colName, "to manifests:", err.Error()) 129 + } 106 130 } 107 131 108 132 return db, nil
+61
pkg/appview/handlers/user.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "html/template" 6 + "net/http" 7 + 8 + "atcr.io/pkg/appview/db" 9 + "atcr.io/pkg/appview/middleware" 10 + "github.com/gorilla/mux" 11 + ) 12 + 13 + // UserPageHandler handles the public user page showing all images for a user 14 + type UserPageHandler struct { 15 + DB *sql.DB 16 + Templates *template.Template 17 + RegistryURL string 18 + } 19 + 20 + func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 + vars := mux.Vars(r) 22 + handle := vars["handle"] 23 + 24 + // Look up user by handle 25 + viewedUser, err := db.GetUserByHandle(h.DB, handle) 26 + if err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + if viewedUser == nil { 32 + http.Error(w, "User not found", http.StatusNotFound) 33 + return 34 + } 35 + 36 + // Fetch pushes for this user (limit to 100 for now) 37 + pushes, _, err := db.GetRecentPushes(h.DB, 100, 0, viewedUser.Handle) 38 + if err != nil { 39 + http.Error(w, err.Error(), http.StatusInternalServerError) 40 + return 41 + } 42 + 43 + data := struct { 44 + User *db.User // Logged-in user (for nav) 45 + ViewedUser *db.User // User whose page we're viewing 46 + Pushes []db.Push 47 + Query string 48 + RegistryURL string 49 + }{ 50 + User: middleware.GetUser(r), // May be nil if not logged in 51 + ViewedUser: viewedUser, 52 + Pushes: pushes, 53 + Query: r.URL.Query().Get("q"), 54 + RegistryURL: h.RegistryURL, 55 + } 56 + 57 + if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil { 58 + http.Error(w, err.Error(), http.StatusInternalServerError) 59 + return 60 + } 61 + }
+27 -10
pkg/appview/jetstream/worker.go
··· 316 316 return fmt.Errorf("failed to marshal manifest: %w", err) 317 317 } 318 318 319 + // Extract OCI annotations from manifest 320 + var title, description, sourceURL, documentationURL, licenses, iconURL string 321 + if manifestRecord.Annotations != nil { 322 + title = manifestRecord.Annotations["org.opencontainers.image.title"] 323 + description = manifestRecord.Annotations["org.opencontainers.image.description"] 324 + sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"] 325 + documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 326 + licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 327 + iconURL = manifestRecord.Annotations["io.atcr.icon"] 328 + } 329 + 319 330 // Insert manifest 320 331 manifestID, err := db.InsertManifest(w.db, &db.Manifest{ 321 - DID: commit.DID, 322 - Repository: manifestRecord.Repository, 323 - Digest: manifestRecord.Digest, 324 - MediaType: manifestRecord.MediaType, 325 - SchemaVersion: manifestRecord.SchemaVersion, 326 - ConfigDigest: manifestRecord.Config.Digest, 327 - ConfigSize: manifestRecord.Config.Size, 328 - RawManifest: string(manifestJSON), 329 - HoldEndpoint: manifestRecord.HoldEndpoint, 330 - CreatedAt: manifestRecord.CreatedAt, 332 + DID: commit.DID, 333 + Repository: manifestRecord.Repository, 334 + Digest: manifestRecord.Digest, 335 + MediaType: manifestRecord.MediaType, 336 + SchemaVersion: manifestRecord.SchemaVersion, 337 + ConfigDigest: manifestRecord.Config.Digest, 338 + ConfigSize: manifestRecord.Config.Size, 339 + RawManifest: string(manifestJSON), 340 + HoldEndpoint: manifestRecord.HoldEndpoint, 341 + CreatedAt: manifestRecord.CreatedAt, 342 + Title: title, 343 + Description: description, 344 + SourceURL: sourceURL, 345 + DocumentationURL: documentationURL, 346 + Licenses: licenses, 347 + IconURL: iconURL, 331 348 }) 332 349 if err != nil { 333 350 return fmt.Errorf("failed to insert manifest: %w", err)
+96 -3
pkg/appview/static/css/style.css
··· 47 47 font-weight: bold; 48 48 } 49 49 50 + .nav-brand .at-protocol { 51 + color: var(--primary); 52 + } 53 + 50 54 .nav-search { 51 55 flex: 1; 52 56 max-width: 400px; ··· 117 121 justify-content: center; 118 122 font-weight: bold; 119 123 text-transform: uppercase; 124 + } 125 + 126 + /* Profile page avatars */ 127 + .profile-avatar { 128 + width: 80px; 129 + height: 80px; 130 + border-radius: 50%; 131 + object-fit: cover; 132 + } 133 + 134 + .profile-avatar-placeholder { 135 + width: 80px; 136 + height: 80px; 137 + border-radius: 50%; 138 + background: var(--primary); 139 + display: flex; 140 + align-items: center; 141 + justify-content: center; 142 + font-weight: bold; 143 + font-size: 2rem; 144 + text-transform: uppercase; 145 + color: white; 146 + } 147 + 148 + .user-profile { 149 + display: flex; 150 + align-items: center; 151 + gap: 1rem; 152 + margin-bottom: 2rem; 153 + } 154 + 155 + .user-profile h1 { 156 + font-size: 1.8rem; 157 + margin: 0; 120 158 } 121 159 122 160 .user-handle { ··· 303 341 padding: 1rem; 304 342 cursor: pointer; 305 343 display: flex; 306 - justify-content: space-between; 307 - align-items: center; 344 + gap: 1rem; 345 + align-items: flex-start; 308 346 background: var(--hover-bg); 309 347 border-radius: 8px 8px 0 0; 310 348 margin: -1rem -1rem 0 -1rem; ··· 314 352 background: #f0f0f0; 315 353 } 316 354 355 + .repo-icon { 356 + width: 48px; 357 + height: 48px; 358 + border-radius: 8px; 359 + object-fit: cover; 360 + flex-shrink: 0; 361 + } 362 + 363 + .repo-info { 364 + flex: 1; 365 + min-width: 0; 366 + } 367 + 368 + .repo-title-row { 369 + display: flex; 370 + align-items: center; 371 + gap: 0.75rem; 372 + margin-bottom: 0.25rem; 373 + } 374 + 317 375 .repo-header h2 { 318 376 font-size: 1.3rem; 319 - margin-bottom: 0.25rem; 377 + margin: 0; 378 + } 379 + 380 + .repo-badge { 381 + display: inline-flex; 382 + align-items: center; 383 + padding: 0.2rem 0.6rem; 384 + font-size: 0.75rem; 385 + font-weight: 500; 386 + border-radius: 12px; 387 + white-space: nowrap; 388 + } 389 + 390 + .license-badge { 391 + background: #e3f2fd; 392 + color: #1976d2; 393 + border: 1px solid #90caf9; 394 + } 395 + 396 + .repo-description { 397 + color: #555; 398 + font-size: 0.95rem; 399 + margin: 0.25rem 0 0.5rem 0; 400 + line-height: 1.4; 320 401 } 321 402 322 403 .repo-stats { ··· 324 405 font-size: 0.9rem; 325 406 display: flex; 326 407 gap: 0.5rem; 408 + align-items: center; 409 + flex-wrap: wrap; 410 + } 411 + 412 + .repo-link { 413 + color: var(--primary); 414 + text-decoration: none; 415 + font-weight: 500; 416 + } 417 + 418 + .repo-link:hover { 419 + text-decoration: underline; 327 420 } 328 421 329 422 .expand-btn {
+1 -1
pkg/appview/templates/components/nav.html
··· 1 1 {{ define "nav" }} 2 2 <nav class="navbar"> 3 3 <div class="nav-brand"> 4 - <a href="/">ATCR</a> 4 + <a href="/"><span class="at-protocol">at://</span>Container Registry</a> 5 5 </div> 6 6 7 7 <div class="nav-search">
+23 -4
pkg/appview/templates/pages/images.html
··· 21 21 {{ $repoName := .Name }} 22 22 <div class="repository-card"> 23 23 <div class="repo-header" onclick="toggleRepo('{{ $repoName }}')"> 24 - <div> 25 - <h2>{{ $repoName }}</h2> 24 + {{ if .IconURL }} 25 + <img src="{{ .IconURL }}" alt="{{ $repoName }}" class="repo-icon"> 26 + {{ end }} 27 + <div class="repo-info"> 28 + <div class="repo-title-row"> 29 + <h2>{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</h2> 30 + {{ if .Licenses }} 31 + <span class="repo-badge license-badge">{{ .Licenses }}</span> 32 + {{ end }} 33 + </div> 34 + {{ if .Description }} 35 + <p class="repo-description">{{ .Description }}</p> 36 + {{ end }} 26 37 <div class="repo-stats"> 27 38 <span>{{ .TagCount }} tags</span> 28 39 <span>•</span> ··· 31 42 <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}"> 32 43 Last push: {{ timeAgo .LastPush }} 33 44 </time> 45 + {{ if .SourceURL }} 46 + <span>•</span> 47 + <a href="{{ .SourceURL }}" target="_blank" onclick="event.stopPropagation()" class="repo-link">Source</a> 48 + {{ end }} 49 + {{ if .DocumentationURL }} 50 + <span>•</span> 51 + <a href="{{ .DocumentationURL }}" target="_blank" onclick="event.stopPropagation()" class="repo-link">Docs</a> 52 + {{ end }} 34 53 </div> 35 54 </div> 36 55 <button class="expand-btn" id="btn-{{ $repoName }}">▼</button> ··· 45 64 <div class="tag-row" id="tag-{{ $repoName }}-{{ .Tag }}"> 46 65 <span class="tag-name">{{ .Tag }}</span> 47 66 <span class="tag-arrow">→</span> 48 - <code class="tag-digest">{{ truncateDigest .Digest 12 }}</code> 67 + <code class="tag-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 49 68 <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 50 69 {{ timeAgo .CreatedAt }} 51 70 </time> ··· 70 89 {{ if .Manifests }} 71 90 {{ range .Manifests }} 72 91 <div class="manifest-row" id="manifest-{{ .Digest }}"> 73 - <code class="manifest-digest">{{ truncateDigest .Digest 12 }}</code> 92 + <code class="manifest-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 74 93 <span>{{ .HoldEndpoint }}</span> 75 94 <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 76 95 {{ timeAgo .CreatedAt }}
+65
pkg/appview/templates/pages/user.html
··· 1 + {{ define "user" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>{{ .ViewedUser.Handle }} - ATCR</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + <script src="/static/js/app.js"></script> 11 + </head> 12 + <body> 13 + {{ template "nav" . }} 14 + 15 + <main class="container"> 16 + <div class="home-page"> 17 + <div class="user-profile"> 18 + {{ if .ViewedUser.Avatar }} 19 + <img src="{{ .ViewedUser.Avatar }}" alt="{{ .ViewedUser.Handle }}" class="profile-avatar"> 20 + {{ else }} 21 + <div class="profile-avatar-placeholder">{{ firstChar .ViewedUser.Handle }}</div> 22 + {{ end }} 23 + <h1>{{ .ViewedUser.Handle }}</h1> 24 + </div> 25 + 26 + {{ if .Pushes }} 27 + {{ range .Pushes }} 28 + <div class="push-card"> 29 + <div class="push-header"> 30 + <span class="push-repo">{{ .Repository }}</span> 31 + <span class="push-separator">:</span> 32 + <span class="push-tag">{{ .Tag }}</span> 33 + </div> 34 + 35 + <div class="push-details"> 36 + <code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 37 + <span class="separator">•</span> 38 + <span class="hold">{{ .HoldEndpoint }}</span> 39 + <span class="separator">•</span> 40 + <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 41 + {{ timeAgo .CreatedAt }} 42 + </time> 43 + </div> 44 + 45 + <div class="push-command"> 46 + <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.ViewedUser.Handle }}/{{ .Repository }}:{{ .Tag }}</code> 47 + <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.ViewedUser.Handle }}/{{ .Repository }}:{{ .Tag }}')"> 48 + 📋 Copy 49 + </button> 50 + </div> 51 + </div> 52 + {{ end }} 53 + {{ else }} 54 + <div class="empty-state"> 55 + <p>No images yet.</p> 56 + </div> 57 + {{ end }} 58 + </div> 59 + </main> 60 + 61 + <!-- Modal container for HTMX --> 62 + <div id="modal"></div> 63 + </body> 64 + </html> 65 + {{ end }}
+2 -2
pkg/appview/templates/partials/push-list.html
··· 1 1 {{ range .Pushes }} 2 2 <div class="push-card"> 3 3 <div class="push-header"> 4 - <a href="/?user={{ .Handle }}" class="push-user">{{ .Handle }}</a> 4 + <a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a> 5 5 <span class="push-separator">/</span> 6 6 <span class="push-repo">{{ .Repository }}</span> 7 7 <span class="push-separator">:</span> ··· 9 9 </div> 10 10 11 11 <div class="push-details"> 12 - <code class="digest">{{ truncateDigest .Digest 12 }}</code> 12 + <code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 13 13 <span class="separator">•</span> 14 14 <span class="hold">{{ .HoldEndpoint }}</span> 15 15 <span class="separator">•</span>