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.

clean up ui elements

+561 -267
-7
cmd/appview/serve.go
··· 197 197 198 198 fmt.Printf("UI enabled:\n") 199 199 fmt.Printf(" - Home: /\n") 200 - fmt.Printf(" - Images: /images\n") 201 200 fmt.Printf(" - Settings: /settings\n") 202 201 } 203 202 ··· 549 548 // Authenticated routes 550 549 authRouter := router.NewRoute().Subrouter() 551 550 authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) 552 - 553 - authRouter.Handle("/images", &uihandlers.ImagesHandler{ 554 - DB: readOnlyDB, // Read-only: just displays user's images 555 - Templates: templates, 556 - RegistryURL: uihandlers.TrimRegistryURL(baseURL), 557 - }).Methods("GET") 558 551 559 552 authRouter.Handle("/settings", &uihandlers.SettingsHandler{ 560 553 Templates: templates,
+40 -7
pkg/appview/db/models.go
··· 52 52 53 53 // Push represents a combined tag and manifest for the recent pushes view 54 54 type Push struct { 55 - DID string 56 - Handle string 57 - Repository string 58 - Tag string 59 - Digest string 60 - HoldEndpoint string 61 - CreatedAt time.Time 55 + DID string 56 + Handle string 57 + Repository string 58 + Tag string 59 + Digest string 60 + Title string 61 + Description string 62 + IconURL string 63 + StarCount int 64 + PullCount int 65 + CreatedAt time.Time 62 66 } 63 67 64 68 // Repository represents an aggregated view of a user's repository ··· 87 91 PushCount int `json:"push_count"` 88 92 LastPush *time.Time `json:"last_push,omitempty"` 89 93 } 94 + 95 + // FeaturedRepository represents a repository in the featured section 96 + type FeaturedRepository struct { 97 + OwnerDID string 98 + OwnerHandle string 99 + Repository string 100 + Title string 101 + Description string 102 + IconURL string 103 + StarCount int 104 + PullCount int 105 + } 106 + 107 + // RepositoryWithStats combines repository data with statistics 108 + type RepositoryWithStats struct { 109 + Repository 110 + Stats RepositoryStats 111 + } 112 + 113 + // RepoCardData contains all data needed to render a repository card 114 + type RepoCardData struct { 115 + OwnerHandle string 116 + Repository string 117 + Title string 118 + Description string 119 + IconURL string 120 + StarCount int 121 + PullCount int 122 + }
+96 -4
pkg/appview/db/queries.go
··· 33 33 // GetRecentPushes fetches recent pushes with pagination 34 34 func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { 35 35 query := ` 36 - SELECT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at 36 + SELECT 37 + u.did, 38 + u.handle, 39 + t.repository, 40 + t.tag, 41 + t.digest, 42 + COALESCE(m.title, ''), 43 + COALESCE(m.description, ''), 44 + COALESCE(m.icon_url, ''), 45 + COALESCE(rs.pull_count, 0), 46 + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 47 + t.created_at 37 48 FROM tags t 38 49 JOIN users u ON t.did = u.did 39 50 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 51 + LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 40 52 ` 41 53 42 54 args := []any{} ··· 58 70 var pushes []Push 59 71 for rows.Next() { 60 72 var p Push 61 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { 73 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil { 62 74 return nil, 0, err 63 75 } 64 76 pushes = append(pushes, p) ··· 90 102 searchPattern := "%" + query + "%" 91 103 92 104 sqlQuery := ` 93 - SELECT DISTINCT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at 105 + SELECT DISTINCT 106 + u.did, 107 + u.handle, 108 + t.repository, 109 + t.tag, 110 + t.digest, 111 + COALESCE(m.title, ''), 112 + COALESCE(m.description, ''), 113 + COALESCE(m.icon_url, ''), 114 + COALESCE(rs.pull_count, 0), 115 + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 116 + t.created_at 94 117 FROM tags t 95 118 JOIN users u ON t.did = u.did 96 119 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 120 + LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 97 121 WHERE u.handle LIKE ? ESCAPE '\' 98 122 OR u.did = ? 99 123 OR t.repository LIKE ? ESCAPE '\' ··· 112 136 var pushes []Push 113 137 for rows.Next() { 114 138 var p Push 115 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { 139 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil { 116 140 return nil, 0, err 117 141 } 118 142 pushes = append(pushes, p) ··· 1086 1110 func (m *MetricsDB) IncrementPushCount(did, repository string) error { 1087 1111 return IncrementPushCount(m.db, did, repository) 1088 1112 } 1113 + 1114 + // GetFeaturedRepositories fetches top repositories sorted by stars and pulls 1115 + func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error) { 1116 + query := ` 1117 + WITH latest_manifests AS ( 1118 + SELECT did, repository, MAX(id) as latest_id 1119 + FROM manifests 1120 + GROUP BY did, repository 1121 + ), 1122 + repo_stats AS ( 1123 + SELECT 1124 + lm.did, 1125 + lm.repository, 1126 + COALESCE(rs.pull_count, 0) as pull_count, 1127 + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) as star_count, 1128 + (COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) * 10) as score 1129 + FROM latest_manifests lm 1130 + LEFT JOIN repository_stats rs ON lm.did = rs.did AND lm.repository = rs.repository 1131 + ) 1132 + SELECT 1133 + m.did, 1134 + u.handle, 1135 + m.repository, 1136 + m.title, 1137 + m.description, 1138 + m.icon_url, 1139 + rs.pull_count, 1140 + rs.star_count 1141 + FROM latest_manifests lm 1142 + JOIN manifests m ON lm.latest_id = m.id 1143 + JOIN users u ON m.did = u.did 1144 + JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository 1145 + ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC 1146 + LIMIT ? 1147 + ` 1148 + 1149 + rows, err := db.Query(query, limit) 1150 + if err != nil { 1151 + return nil, err 1152 + } 1153 + defer rows.Close() 1154 + 1155 + var featured []FeaturedRepository 1156 + for rows.Next() { 1157 + var f FeaturedRepository 1158 + var title, description, iconURL sql.NullString 1159 + 1160 + if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, 1161 + &title, &description, &iconURL, &f.PullCount, &f.StarCount); err != nil { 1162 + return nil, err 1163 + } 1164 + 1165 + // Convert NullString to string 1166 + if title.Valid { 1167 + f.Title = title.String 1168 + } 1169 + if description.Valid { 1170 + f.Description = description.String 1171 + } 1172 + if iconURL.Valid { 1173 + f.IconURL = iconURL.String 1174 + } 1175 + 1176 + featured = append(featured, f) 1177 + } 1178 + 1179 + return featured, nil 1180 + }
+24 -1
pkg/appview/handlers/home.go
··· 17 17 } 18 18 19 19 func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 + // Fetch featured repositories (top 6) 21 + featured, err := db.GetFeaturedRepositories(h.DB, 6) 22 + if err != nil { 23 + // Log error but continue - featured section will be empty 24 + featured = []db.FeaturedRepository{} 25 + } 26 + 27 + // Convert to RepoCardData for template 28 + cards := make([]db.RepoCardData, len(featured)) 29 + for i, repo := range featured { 30 + cards[i] = db.RepoCardData{ 31 + OwnerHandle: repo.OwnerHandle, 32 + Repository: repo.Repository, 33 + Title: repo.Title, 34 + Description: repo.Description, 35 + IconURL: repo.IconURL, 36 + StarCount: repo.StarCount, 37 + PullCount: repo.PullCount, 38 + } 39 + } 40 + 20 41 data := struct { 21 42 PageData 43 + FeaturedRepos []db.RepoCardData 22 44 }{ 23 - PageData: NewPageData(r, h.RegistryURL), 45 + PageData: NewPageData(r, h.RegistryURL), 46 + FeaturedRepos: cards, 24 47 } 25 48 26 49 if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil {
-36
pkg/appview/handlers/images.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "html/template" 6 5 "net/http" 7 6 8 7 "atcr.io/pkg/appview/db" 9 8 "atcr.io/pkg/appview/middleware" 10 9 "github.com/gorilla/mux" 11 10 ) 12 - 13 - // ImagesHandler handles the images management page 14 - type ImagesHandler struct { 15 - DB *sql.DB 16 - Templates *template.Template 17 - RegistryURL string 18 - } 19 - 20 - func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 - user := middleware.GetUser(r) 22 - if user == nil { 23 - http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound) 24 - return 25 - } 26 - 27 - // Fetch repositories from database (cached firehose data) 28 - repos, err := db.GetUserRepositories(h.DB, user.DID) 29 - if err != nil { 30 - http.Error(w, err.Error(), http.StatusInternalServerError) 31 - return 32 - } 33 - 34 - data := struct { 35 - PageData 36 - Repositories []db.Repository 37 - }{ 38 - PageData: NewPageData(r, h.RegistryURL), 39 - Repositories: repos, 40 - } 41 - 42 - if err := h.Templates.ExecuteTemplate(w, "images", data); err != nil { 43 - http.Error(w, err.Error(), http.StatusInternalServerError) 44 - return 45 - } 46 - } 47 11 48 12 // DeleteTagHandler handles deleting a tag 49 13 type DeleteTagHandler struct {
+8
pkg/appview/handlers/repository.go
··· 78 78 } 79 79 } 80 80 81 + // Check if current user is the repository owner 82 + isOwner := false 83 + if user != nil { 84 + isOwner = (user.DID == owner.DID) 85 + } 86 + 81 87 data := struct { 82 88 PageData 83 89 Owner *db.User // Repository owner 84 90 Repository *db.Repository 85 91 StarCount int 86 92 IsStarred bool 93 + IsOwner bool // Whether current user owns this repository 87 94 }{ 88 95 PageData: NewPageData(r, h.RegistryURL), 89 96 Owner: owner, 90 97 Repository: repo, 91 98 StarCount: stats.StarCount, 92 99 IsStarred: isStarred, 100 + IsOwner: isOwner, 93 101 } 94 102 95 103 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+29 -7
pkg/appview/handlers/user.go
··· 32 32 return 33 33 } 34 34 35 - // Fetch pushes for this user (limit to 100 for now) 36 - pushes, _, err := db.GetRecentPushes(h.DB, 100, 0, viewedUser.Handle) 35 + // Fetch repositories for this user 36 + repos, err := db.GetUserRepositories(h.DB, viewedUser.DID) 37 37 if err != nil { 38 38 http.Error(w, err.Error(), http.StatusInternalServerError) 39 39 return 40 40 } 41 41 42 + // Convert to RepoCardData for template 43 + cards := make([]db.RepoCardData, 0, len(repos)) 44 + for _, repo := range repos { 45 + stats, err := db.GetRepositoryStats(h.DB, viewedUser.DID, repo.Name) 46 + if err != nil { 47 + // Continue with zero stats on error 48 + stats = &db.RepositoryStats{ 49 + DID: viewedUser.DID, 50 + Repository: repo.Name, 51 + } 52 + } 53 + cards = append(cards, db.RepoCardData{ 54 + OwnerHandle: viewedUser.Handle, 55 + Repository: repo.Name, 56 + Title: repo.Title, 57 + Description: repo.Description, 58 + IconURL: repo.IconURL, 59 + StarCount: stats.StarCount, 60 + PullCount: stats.PullCount, 61 + }) 62 + } 63 + 42 64 data := struct { 43 65 PageData 44 - ViewedUser *db.User // User whose page we're viewing 45 - Pushes []db.Push 66 + ViewedUser *db.User // User whose page we're viewing 67 + Repositories []db.RepoCardData 46 68 }{ 47 - PageData: NewPageData(r, h.RegistryURL), 48 - ViewedUser: viewedUser, 49 - Pushes: pushes, 69 + PageData: NewPageData(r, h.RegistryURL), 70 + ViewedUser: viewedUser, 71 + Repositories: cards, 50 72 } 51 73 52 74 if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil {
+256 -39
pkg/appview/static/css/style.css
··· 5 5 --danger: #dc3545; 6 6 --bg: #ffffff; 7 7 --fg: #1a1a1a; 8 + --border-dark: #666; 8 9 --border: #e0e0e0; 9 10 --code-bg: #f5f5f5; 10 11 --hover-bg: #f9f9f9; 12 + --star: #fbbf24; 11 13 } 12 14 13 15 * { ··· 142 144 font-weight: bold; 143 145 font-size: 2rem; 144 146 text-transform: uppercase; 145 - color: white; 147 + color: var(--bg); 146 148 } 147 149 148 150 .user-profile { ··· 158 160 } 159 161 160 162 .user-handle { 161 - color: white; 163 + color: var(--bg); 162 164 font-size: 0.95rem; 163 165 } 164 166 ··· 272 274 } 273 275 274 276 .push-header { 275 - font-size: 1.1rem; 276 - margin-bottom: 0.5rem; 277 + display: flex; 278 + gap: 1rem; 279 + align-items: flex-start; 280 + margin-bottom: 0.75rem; 277 281 } 278 282 279 283 .push-user { ··· 287 291 } 288 292 289 293 .push-separator { 290 - color: #999; 294 + color: var(--border-dark); 291 295 margin: 0 0.25rem; 292 296 } 293 297 294 298 .push-repo { 295 299 font-weight: 500; 296 - color: var(--fg); 300 + color: var(--primary); 297 301 text-decoration: none; 298 302 } 299 303 ··· 307 311 } 308 312 309 313 .push-details { 310 - display: flex; 311 - gap: 0.5rem; 312 - align-items: center; 313 - color: #666; 314 + color: var(--border-dark); 314 315 font-size: 0.9rem; 315 - margin-bottom: 0.5rem; 316 + margin-bottom: 0.75rem; 316 317 } 317 318 318 319 .digest { ··· 324 325 } 325 326 326 327 .separator { 327 - color: #ccc; 328 + color: var(--border); 328 329 } 329 330 330 - .push-command { 331 + /* Push card icon and layout */ 332 + .push-icon { 333 + width: 48px; 334 + height: 48px; 335 + border-radius: 8px; 336 + object-fit: cover; 337 + flex-shrink: 0; 338 + } 339 + 340 + .push-icon-placeholder { 341 + width: 48px; 342 + height: 48px; 343 + border-radius: 8px; 344 + background: var(--primary); 345 + display: flex; 346 + align-items: center; 347 + justify-content: center; 348 + font-weight: bold; 349 + font-size: 1.5rem; 350 + text-transform: uppercase; 351 + color: var(--bg); 352 + flex-shrink: 0; 353 + } 354 + 355 + .push-info { 356 + flex: 1; 357 + min-width: 0; 358 + } 359 + 360 + .push-title-row { 331 361 display: flex; 332 - gap: 0.5rem; 362 + justify-content: space-between; 333 363 align-items: center; 334 - margin-top: 0.5rem; 335 - padding: 0.5rem; 336 - background: var(--code-bg); 337 - border-radius: 4px; 364 + gap: 1rem; 365 + margin-bottom: 0.25rem; 338 366 } 339 367 340 - .pull-command { 368 + .push-title { 369 + font-size: 1.1rem; 341 370 flex: 1; 342 - font-family: 'Monaco', 'Courier New', monospace; 371 + } 372 + 373 + .push-description { 374 + color: var(--border-dark); 343 375 font-size: 0.9rem; 376 + line-height: 1.4; 377 + margin: 0.25rem 0 0 0; 378 + } 379 + 380 + /* Push stats */ 381 + .push-stats { 382 + display: flex; 383 + gap: 1rem; 384 + align-items: center; 385 + flex-shrink: 0; 386 + } 387 + 388 + .push-stat { 389 + display: flex; 390 + align-items: center; 391 + gap: 0.35rem; 392 + color: var(--border-dark); 393 + font-size: 0.9rem; 394 + } 395 + 396 + .push-stat .star-icon { 397 + color: var(--star); 398 + font-size: 1rem; 399 + } 400 + 401 + .push-stat .pull-icon { 402 + color: var(--primary); 403 + font-size: 1rem; 404 + } 405 + 406 + .push-stat .stat-count { 407 + font-weight: 600; 408 + color: var(--fg); 344 409 } 345 410 346 411 /* Repository Cards */ ··· 356 421 } 357 422 358 423 .repo-header:hover { 359 - background: #f0f0f0; 424 + background: var(--hover-bg); 360 425 } 361 426 362 427 .repo-icon { ··· 405 470 } 406 471 407 472 .license-badge { 408 - background: #e3f2fd; 409 - color: #1976d2; 473 + background: var(--code-bg); 474 + color: var(--primary); 410 475 border: 1px solid #90caf9; 411 476 } 412 477 413 478 .repo-description { 414 - color: #555; 479 + color: var(--border-dark); 415 480 font-size: 0.95rem; 416 481 margin: 0.25rem 0 0.5rem 0; 417 482 line-height: 1.4; 418 483 } 419 484 420 485 .repo-stats { 421 - color: #666; 486 + color:var(--border-dark); 422 487 font-size: 0.9rem; 423 488 display: flex; 424 489 gap: 0.5rem; ··· 475 540 } 476 541 477 542 .tag-arrow { 478 - color: #999; 543 + color: var(--border-dark); 479 544 } 480 545 481 546 .tag-digest, .manifest-digest { ··· 531 596 .form-group small { 532 597 display: block; 533 598 margin-top: 0.25rem; 534 - color: #666; 599 + color: var(--border-dark); 535 600 font-size: 0.85rem; 536 601 } 537 602 ··· 560 625 } 561 626 562 627 .modal-content { 563 - background: white; 628 + background: var(--bg); 564 629 padding: 2rem; 565 630 border-radius: 8px; 566 631 max-width: 800px; ··· 599 664 .loading { 600 665 text-align: center; 601 666 padding: 2rem; 602 - color: #666; 667 + color: var(--border-dark); 603 668 } 604 669 605 670 .empty-state { ··· 624 689 } 625 690 626 691 .empty-message { 627 - color: #999; 692 + color: var(--border-dark); 628 693 font-style: italic; 629 694 padding: 1rem; 630 695 } ··· 768 833 font-weight: bold; 769 834 font-size: 2.5rem; 770 835 text-transform: uppercase; 771 - color: white; 836 + color: var(--bg); 772 837 flex-shrink: 0; 773 838 } 774 839 ··· 791 856 } 792 857 793 858 .repo-separator { 794 - color: #999; 859 + color: var(--border-dark); 795 860 margin: 0 0.25rem; 796 861 } 797 862 ··· 800 865 } 801 866 802 867 .repo-hero-description { 803 - color: #555; 868 + color: var(--border-dark); 804 869 font-size: 1.1rem; 805 870 line-height: 1.5; 806 871 margin: 0.5rem 0 0 0; ··· 835 900 } 836 901 837 902 .star-btn.starred { 838 - border-color: #fbbf24; 839 - background: #fffbeb; 903 + border-color:var(--star); 904 + background: var(--code-bg); 840 905 } 841 906 842 907 .star-btn.starred:hover:not(:disabled) { 843 - background: #fef3c7; 908 + background: var(--hover-bg); 844 909 } 845 910 846 911 .star-icon { 847 912 font-size: 1.25rem; 848 913 line-height: 1; 849 914 transition: transform 0.2s ease; 850 - color: #fbbf24; 915 + color:var(--star); 851 916 } 852 917 853 918 .star-btn:hover:not(:disabled) .star-icon { ··· 943 1008 } 944 1009 945 1010 .tag-timestamp { 946 - color: #666; 1011 + color: var(--border-dark); 947 1012 font-size: 0.9rem; 948 1013 } 949 1014 ··· 955 1020 display: flex; 956 1021 gap: 0.5rem; 957 1022 align-items: center; 958 - color: #666; 1023 + color: var(--border-dark); 959 1024 font-size: 0.9rem; 960 1025 margin-top: 0.5rem; 961 1026 } ··· 965 1030 color: var(--secondary); 966 1031 } 967 1032 1033 + /* Featured Repositories Section */ 1034 + .featured-section { 1035 + margin-bottom: 3rem; 1036 + } 1037 + 1038 + .featured-section h1 { 1039 + font-size: 1.8rem; 1040 + margin-bottom: 1.5rem; 1041 + } 1042 + 1043 + .featured-grid { 1044 + display: grid; 1045 + grid-template-columns: repeat(3, 1fr); 1046 + gap: 1.5rem; 1047 + margin-bottom: 2rem; 1048 + } 1049 + 1050 + .featured-card { 1051 + border: 1px solid var(--border); 1052 + border-radius: 8px; 1053 + padding: 1.5rem; 1054 + background: var(--bg); 1055 + box-shadow: 0 1px 3px rgba(0,0,0,0.05); 1056 + transition: all 0.2s ease; 1057 + text-decoration: none; 1058 + color: var(--fg); 1059 + display: flex; 1060 + flex-direction: column; 1061 + justify-content: space-between; 1062 + min-height: 180px; 1063 + } 1064 + 1065 + .featured-card:hover { 1066 + box-shadow: 0 4px 8px rgba(0,0,0,0.1); 1067 + border-color: var(--primary); 1068 + transform: translateY(-2px); 1069 + } 1070 + 1071 + .featured-header { 1072 + display: flex; 1073 + gap: 1rem; 1074 + align-items: flex-start; 1075 + margin-bottom: 1rem; 1076 + } 1077 + 1078 + .featured-icon { 1079 + width: 48px; 1080 + height: 48px; 1081 + border-radius: 8px; 1082 + object-fit: cover; 1083 + flex-shrink: 0; 1084 + } 1085 + 1086 + .featured-icon-placeholder { 1087 + width: 48px; 1088 + height: 48px; 1089 + border-radius: 8px; 1090 + background: var(--primary); 1091 + display: flex; 1092 + align-items: center; 1093 + justify-content: center; 1094 + font-weight: bold; 1095 + font-size: 1.5rem; 1096 + text-transform: uppercase; 1097 + color:var(--bg); 1098 + flex-shrink: 0; 1099 + } 1100 + 1101 + .featured-info { 1102 + flex: 1; 1103 + min-width: 0; 1104 + } 1105 + 1106 + .featured-title { 1107 + font-size: 1.1rem; 1108 + font-weight: 600; 1109 + margin-bottom: 0.5rem; 1110 + line-height: 1.3; 1111 + } 1112 + 1113 + .featured-owner { 1114 + color: var(--primary); 1115 + } 1116 + 1117 + .featured-separator { 1118 + color: var(--border-dark); 1119 + margin: 0 0.25rem; 1120 + } 1121 + 1122 + .featured-name { 1123 + color: var(--fg); 1124 + } 1125 + 1126 + .featured-description { 1127 + color: var(--border-dark); 1128 + font-size: 0.9rem; 1129 + line-height: 1.4; 1130 + margin: 0; 1131 + overflow: hidden; 1132 + text-overflow: ellipsis; 1133 + display: -webkit-box; 1134 + -webkit-line-clamp: 2; 1135 + -webkit-box-orient: vertical; 1136 + line-clamp: 2; 1137 + } 1138 + 1139 + .featured-stats { 1140 + display: flex; 1141 + gap: 1.5rem; 1142 + align-items: center; 1143 + padding-top: 0.75rem; 1144 + border-top: 1px solid var(--border); 1145 + } 1146 + 1147 + .featured-stat { 1148 + display: flex; 1149 + align-items: center; 1150 + gap: 0.5rem; 1151 + color: var(--border-dark); 1152 + font-size: 0.95rem; 1153 + } 1154 + 1155 + .featured-stat .star-icon { 1156 + color: var(--star); 1157 + font-size: 1.1rem; 1158 + } 1159 + 1160 + .featured-stat .pull-icon { 1161 + color: var(--primary); 1162 + font-size: 1.1rem; 1163 + } 1164 + 1165 + .featured-stat .stat-count { 1166 + font-weight: 600; 1167 + color: var(--fg); 1168 + } 1169 + 968 1170 /* Responsive */ 969 1171 @media (max-width: 768px) { 970 1172 .navbar { ··· 1007 1209 .manifest-item-details { 1008 1210 flex-direction: column; 1009 1211 align-items: flex-start; 1212 + } 1213 + 1214 + .featured-grid { 1215 + grid-template-columns: 1fr; 1216 + gap: 1rem; 1217 + } 1218 + 1219 + .featured-card { 1220 + min-height: auto; 1221 + } 1222 + } 1223 + 1224 + @media (max-width: 1024px) and (min-width: 769px) { 1225 + .featured-grid { 1226 + grid-template-columns: repeat(2, 1fr); 1010 1227 } 1011 1228 }
+1 -1
pkg/appview/templates/components/nav.html
··· 25 25 </svg> 26 26 </button> 27 27 <div class="dropdown-menu" id="user-dropdown-menu" hidden> 28 - <a href="/images" class="dropdown-item">Your Images</a> 28 + <a href="/u/{{ .User.Handle }}" class="dropdown-item">Your Repositories</a> 29 29 <a href="/settings" class="dropdown-item">Settings</a> 30 30 <hr class="dropdown-divider"> 31 31 <form action="/auth/logout" method="POST">
+43
pkg/appview/templates/components/repo-card.html
··· 1 + {{ define "repo-card" }} 2 + {{/* 3 + Repository card component - displays a repository as a clickable card 4 + 5 + Expects: db.RepoCardData struct with fields: 6 + - OwnerHandle: string - Repository owner's handle 7 + - Repository: string - Repository name 8 + - Title: string (optional) - Display title 9 + - Description: string (optional) - Repository description 10 + - IconURL: string (optional) - Repository icon URL 11 + - StarCount: int - Number of stars 12 + - PullCount: int - Number of pulls 13 + */}} 14 + <a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="featured-card"> 15 + <div class="featured-header"> 16 + {{ if .IconURL }} 17 + <img src="{{ .IconURL }}" alt="{{ .Repository }}" class="featured-icon"> 18 + {{ else }} 19 + <div class="featured-icon-placeholder">{{ firstChar .Repository }}</div> 20 + {{ end }} 21 + <div class="featured-info"> 22 + <div class="featured-title"> 23 + <span class="featured-owner">{{ .OwnerHandle }}</span> 24 + <span class="featured-separator">/</span> 25 + <span class="featured-name">{{ .Repository }}</span> 26 + </div> 27 + {{ if .Description }} 28 + <p class="featured-description">{{ .Description }}</p> 29 + {{ end }} 30 + </div> 31 + </div> 32 + <div class="featured-stats"> 33 + <span class="featured-stat"> 34 + <span class="star-icon">★</span> 35 + <span class="stat-count">{{ .StarCount }}</span> 36 + </span> 37 + <span class="featured-stat"> 38 + <span class="pull-icon">↓</span> 39 + <span class="stat-count">{{ .PullCount }}</span> 40 + </span> 41 + </div> 42 + </a> 43 + {{ end }}
+14 -1
pkg/appview/templates/pages/home.html
··· 13 13 14 14 <main class="container"> 15 15 <div class="home-page"> 16 - <h1>Recent Pushes</h1> 16 + <!-- Featured Repositories Section --> 17 + {{ if .FeaturedRepos }} 18 + <div class="featured-section"> 19 + <h1>Featured</h1> 20 + <div class="featured-grid"> 21 + {{ range .FeaturedRepos }} 22 + {{ template "repo-card" . }} 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + <!-- Recent Pushes Section --> 29 + <h1>What's New</h1> 17 30 18 31 <div id="push-list" hx-get="/api/recent-pushes" hx-trigger="load" hx-swap="innerHTML"> 19 32 <!-- Initial loading state -->
-119
pkg/appview/templates/pages/images.html
··· 1 - {{ define "images" }} 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>Your Images - 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="images-page"> 17 - <h1>Your Images</h1> 18 - 19 - {{ if .Repositories }} 20 - {{ range .Repositories }} 21 - {{ $repoName := .Name }} 22 - <div class="repository-card"> 23 - <div class="repo-header"> 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><a href="/r/{{ $.User.Handle }}/{{ $repoName }}" class="repo-title-link">{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</a></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 }} 37 - <div class="repo-stats"> 38 - <span>{{ .TagCount }} tags</span> 39 - <span>•</span> 40 - <span>{{ .ManifestCount }} manifests</span> 41 - <span>•</span> 42 - <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}"> 43 - Last push: {{ timeAgo .LastPush }} 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 }} 53 - </div> 54 - </div> 55 - <button class="expand-btn" id="btn-{{ $repoName }}" onclick="toggleRepo('{{ $repoName }}'); event.stopPropagation();">▼</button> 56 - </div> 57 - 58 - <div id="repo-{{ $repoName }}" class="repo-details" style="display: none;"> 59 - <!-- Tags Section --> 60 - <div class="tags-section"> 61 - <h3>Tags</h3> 62 - {{ if .Tags }} 63 - {{ range .Tags }} 64 - <div class="tag-row" id="tag-{{ $repoName }}-{{ .Tag }}"> 65 - <span class="tag-name">{{ .Tag }}</span> 66 - <span class="tag-arrow">→</span> 67 - <code class="tag-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 68 - <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 69 - {{ timeAgo .CreatedAt }} 70 - </time> 71 - 72 - <button class="delete-btn" 73 - hx-delete="/api/images/{{ $repoName }}/tags/{{ .Tag }}" 74 - hx-confirm="Delete tag {{ .Tag }}?" 75 - hx-target="#tag-{{ $repoName }}-{{ .Tag }}" 76 - hx-swap="outerHTML"> 77 - 🗑️ 78 - </button> 79 - </div> 80 - {{ end }} 81 - {{ else }} 82 - <p class="empty-message">No tags for this repository</p> 83 - {{ end }} 84 - </div> 85 - 86 - <!-- Manifests Section --> 87 - <div class="manifests-section"> 88 - <h3>Manifests</h3> 89 - {{ if .Manifests }} 90 - {{ range .Manifests }} 91 - <div class="manifest-row" id="manifest-{{ .Digest }}"> 92 - <code class="manifest-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 93 - <span>{{ .HoldEndpoint }}</span> 94 - <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 95 - {{ timeAgo .CreatedAt }} 96 - </time> 97 - </div> 98 - {{ end }} 99 - {{ else }} 100 - <p class="empty-message">No manifests for this repository</p> 101 - {{ end }} 102 - </div> 103 - </div> 104 - </div> 105 - {{ end }} 106 - {{ else }} 107 - <div class="empty-state"> 108 - <p>No images yet. Push your first image:</p> 109 - <pre><code>docker push {{ .RegistryURL }}/{{ .User.Handle }}/myapp:latest</code></pre> 110 - </div> 111 - {{ end }} 112 - </div> 113 - </main> 114 - 115 - <!-- Modal container for HTMX --> 116 - <div id="modal"></div> 117 - </body> 118 - </html> 119 - {{ end }}
+15 -4
pkg/appview/templates/pages/repository.html
··· 89 89 {{ if .Repository.Tags }} 90 90 <div class="tags-list"> 91 91 {{ range .Repository.Tags }} 92 - <div class="tag-item"> 92 + <div class="tag-item" id="tag-{{ .Tag }}"> 93 93 <div class="tag-item-header"> 94 94 <span class="tag-name-large">{{ .Tag }}</span> 95 - <time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 96 - {{ timeAgo .CreatedAt }} 97 - </time> 95 + <div style="display: flex; gap: 1rem; align-items: center;"> 96 + <time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 97 + {{ timeAgo .CreatedAt }} 98 + </time> 99 + {{ if $.IsOwner }} 100 + <button class="delete-btn" 101 + hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag }}" 102 + hx-confirm="Delete tag {{ .Tag }}?" 103 + hx-target="#tag-{{ .Tag }}" 104 + hx-swap="outerHTML"> 105 + 🗑️ 106 + </button> 107 + {{ end }} 108 + </div> 98 109 </div> 99 110 <div class="tag-item-details"> 100 111 <code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
+5 -26
pkg/appview/templates/pages/user.html
··· 23 23 <h1>{{ .ViewedUser.Handle }}</h1> 24 24 </div> 25 25 26 - {{ if .Pushes }} 27 - {{ range .Pushes }} 28 - <div class="push-card"> 29 - <div class="push-header"> 30 - <a href="/r/{{ $.ViewedUser.Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a> 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> 26 + {{ if .Repositories }} 27 + <div class="featured-grid"> 28 + {{ range .Repositories }} 29 + {{ template "repo-card" . }} 30 + {{ end }} 51 31 </div> 52 - {{ end }} 53 32 {{ else }} 54 33 <div class="empty-state"> 55 34 <p>No images yet.</p>
+30 -15
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="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a> 5 - <span class="push-separator">/</span> 6 - <a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a> 7 - <span class="push-separator">:</span> 8 - <span class="push-tag">{{ .Tag }}</span> 4 + {{ if .IconURL }} 5 + <img src="{{ .IconURL }}" alt="{{ .Repository }}" class="push-icon"> 6 + {{ else }} 7 + <div class="push-icon-placeholder">{{ firstChar .Repository }}</div> 8 + {{ end }} 9 + <div class="push-info"> 10 + <div class="push-title-row"> 11 + <div class="push-title"> 12 + <a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a> 13 + <span class="push-separator">/</span> 14 + <a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a> 15 + <span class="push-separator">:</span> 16 + <span class="push-tag">{{ .Tag }}</span> 17 + </div> 18 + <div class="push-stats"> 19 + <span class="push-stat"> 20 + <span class="star-icon">★</span> 21 + <span class="stat-count">{{ .StarCount }}</span> 22 + </span> 23 + <span class="push-stat"> 24 + <span class="pull-icon">↓</span> 25 + <span class="stat-count">{{ .PullCount }}</span> 26 + </span> 27 + </div> 28 + </div> 29 + {{ if .Description }} 30 + <p class="push-description">{{ .Description }}</p> 31 + {{ end }} 32 + </div> 9 33 </div> 10 34 11 35 <div class="push-details"> 12 - <code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 13 - <span class="separator">•</span> 14 - <span class="hold">{{ .HoldEndpoint }}</span> 36 + <code class="digest" title="{{ .Digest }}">{{ .Digest }}</code> 15 37 <span class="separator">•</span> 16 38 <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 17 39 {{ timeAgo .CreatedAt }} 18 40 </time> 19 - </div> 20 - 21 - <div class="push-command"> 22 - <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code> 23 - <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')"> 24 - 📋 Copy 25 - </button> 26 41 </div> 27 42 </div> 28 43 {{ end }}