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.

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 }}