A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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>