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

lucide icon pack. clean up some templates/css

evan.jarrett.net f07376c3 2f2b8c82

verified
+3
pkg/appview/db/models.go
··· 70 70 IconURL string 71 71 StarCount int 72 72 PullCount int 73 + IsStarred bool // Whether the current user has starred this repository 73 74 CreatedAt time.Time 74 75 HoldEndpoint string // Hold endpoint for health checking 75 76 Reachable bool // Whether the hold endpoint is reachable ··· 114 115 IconURL string 115 116 StarCount int 116 117 PullCount int 118 + IsStarred bool // Whether the current user has starred this repository 117 119 } 118 120 119 121 // RepositoryWithStats combines repository data with statistics ··· 131 133 IconURL string 132 134 StarCount int 133 135 PullCount int 136 + IsStarred bool // Whether the current user has starred this repository 134 137 } 135 138 136 139 // PlatformInfo represents platform information (OS/Architecture)
+19 -10
pkg/appview/db/queries.go
··· 31 31 } 32 32 33 33 // GetRecentPushes fetches recent pushes with pagination 34 - func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { 34 + func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUserDID string) ([]Push, int, error) { 35 35 query := ` 36 36 SELECT 37 37 u.did, ··· 44 44 COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''), 45 45 COALESCE(rs.pull_count, 0), 46 46 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 47 + COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 47 48 t.created_at, 48 49 m.hold_endpoint 49 50 FROM tags t ··· 52 53 LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 53 54 ` 54 55 55 - args := []any{} 56 + args := []any{currentUserDID} 56 57 57 58 if userFilter != "" { 58 59 query += " WHERE u.handle = ? OR u.did = ?" ··· 71 72 var pushes []Push 72 73 for rows.Next() { 73 74 var p Push 74 - 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, &p.HoldEndpoint); err != nil { 75 + var isStarredInt int 76 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil { 75 77 return nil, 0, err 76 78 } 79 + p.IsStarred = isStarredInt > 0 77 80 pushes = append(pushes, p) 78 81 } 79 82 ··· 95 98 } 96 99 97 100 // SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations 98 - func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) { 101 + func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID string) ([]Push, int, error) { 99 102 // Escape LIKE wildcards so they're treated literally 100 103 query = escapeLikePattern(query) 101 104 ··· 114 117 COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''), 115 118 COALESCE(rs.pull_count, 0), 116 119 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 120 + COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 117 121 t.created_at, 118 122 m.hold_endpoint 119 123 FROM tags t ··· 132 136 LIMIT ? OFFSET ? 133 137 ` 134 138 135 - rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, limit, offset) 139 + rows, err := db.Query(sqlQuery, currentUserDID, searchPattern, query, searchPattern, searchPattern, limit, offset) 136 140 if err != nil { 137 141 return nil, 0, err 138 142 } ··· 141 145 var pushes []Push 142 146 for rows.Next() { 143 147 var p Push 144 - 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, &p.HoldEndpoint); err != nil { 148 + var isStarredInt int 149 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil { 145 150 return nil, 0, err 146 151 } 152 + p.IsStarred = isStarredInt > 0 147 153 pushes = append(pushes, p) 148 154 } 149 155 ··· 1571 1577 } 1572 1578 1573 1579 // GetFeaturedRepositories fetches top repositories sorted by stars and pulls 1574 - func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error) { 1580 + func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]FeaturedRepository, error) { 1575 1581 query := ` 1576 1582 WITH latest_manifests AS ( 1577 1583 SELECT did, repository, MAX(id) as latest_id ··· 1596 1602 COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''), 1597 1603 COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''), 1598 1604 rs.pull_count, 1599 - rs.star_count 1605 + rs.star_count, 1606 + COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0) 1600 1607 FROM latest_manifests lm 1601 1608 JOIN manifests m ON lm.latest_id = m.id 1602 1609 JOIN users u ON m.did = u.did ··· 1605 1612 LIMIT ? 1606 1613 ` 1607 1614 1608 - rows, err := db.Query(query, limit) 1615 + rows, err := db.Query(query, currentUserDID, limit) 1609 1616 if err != nil { 1610 1617 return nil, err 1611 1618 } ··· 1614 1621 var featured []FeaturedRepository 1615 1622 for rows.Next() { 1616 1623 var f FeaturedRepository 1624 + var isStarredInt int 1617 1625 1618 1626 if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, 1619 - &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount); err != nil { 1627 + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil { 1620 1628 return nil, err 1621 1629 } 1630 + f.IsStarred = isStarredInt > 0 1622 1631 1623 1632 featured = append(featured, f) 1624 1633 }
+16 -2
pkg/appview/handlers/home.go
··· 11 11 12 12 "atcr.io/pkg/appview/db" 13 13 "atcr.io/pkg/appview/holdhealth" 14 + "atcr.io/pkg/appview/middleware" 14 15 ) 15 16 16 17 // HomeHandler handles the home page ··· 21 22 } 22 23 23 24 func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 25 + // Get current user DID (empty string if not logged in) 26 + var currentUserDID string 27 + if user := middleware.GetUser(r); user != nil { 28 + currentUserDID = user.DID 29 + } 30 + 24 31 // Fetch featured repositories (top 6) 25 - featured, err := db.GetFeaturedRepositories(h.DB, 6) 32 + featured, err := db.GetFeaturedRepositories(h.DB, 6, currentUserDID) 26 33 if err != nil { 27 34 // Log error but continue - featured section will be empty 28 35 featured = []db.FeaturedRepository{} ··· 39 46 IconURL: repo.IconURL, 40 47 StarCount: repo.StarCount, 41 48 PullCount: repo.PullCount, 49 + IsStarred: repo.IsStarred, 42 50 } 43 51 } 44 52 ··· 77 85 userFilter = r.URL.Query().Get("q") 78 86 } 79 87 80 - pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter) 88 + // Get current user DID (empty string if not logged in) 89 + var currentUserDID string 90 + if user := middleware.GetUser(r); user != nil { 91 + currentUserDID = user.DID 92 + } 93 + 94 + pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter, currentUserDID) 81 95 if err != nil { 82 96 http.Error(w, err.Error(), http.StatusInternalServerError) 83 97 return
+8 -1
pkg/appview/handlers/search.go
··· 8 8 "strings" 9 9 10 10 "atcr.io/pkg/appview/db" 11 + "atcr.io/pkg/appview/middleware" 11 12 ) 12 13 13 14 // SearchHandler handles the search page ··· 77 78 offset, _ = strconv.Atoi(o) 78 79 } 79 80 80 - pushes, total, err := db.SearchPushes(h.DB, query, limit, offset) 81 + // Get current user DID (empty string if not logged in) 82 + var currentUserDID string 83 + if user := middleware.GetUser(r); user != nil { 84 + currentUserDID = user.DID 85 + } 86 + 87 + pushes, total, err := db.SearchPushes(h.DB, query, limit, offset, currentUserDID) 81 88 if err != nil { 82 89 http.Error(w, err.Error(), http.StatusInternalServerError) 83 90 return
+1 -1
pkg/appview/handlers/settings.go
··· 129 129 } 130 130 131 131 w.Header().Set("Content-Type", "text/html") 132 - w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`)) 132 + w.Write([]byte(`<div class="success"><i data-lucide="check"></i> Default hold updated successfully!</div>`)) 133 133 }
+226 -24
pkg/appview/static/css/style.css
··· 24 24 /* Button text color */ 25 25 --btn-text: #ffffff; 26 26 27 - /* Theme toggle icon */ 28 - --theme-icon: '🌙'; 29 - 30 27 /* Shadows */ 31 28 --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); 32 29 --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1); ··· 61 58 --success: #34d399; 62 59 --success-bg: #064e3b; 63 60 --warning: #fbbf24; 64 - --warning-bg: #78350f; 61 + --warning-bg: #422006; 65 62 --danger: #dc3545; 66 63 --danger-bg: #7f1d1d; 67 64 --bg: #2a2a2a; ··· 78 75 79 76 /* Button text color */ 80 77 --btn-text: #ffffff; 81 - 82 - /* Theme toggle icon */ 83 - --theme-icon: '☀️'; 84 78 85 79 /* Shadows - lighter for dark backgrounds */ 86 80 --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); ··· 347 341 text-decoration: none; 348 342 } 349 343 350 - .theme-toggle-btn::before { 351 - content: var(--theme-icon); 352 - font-size: 1.2rem; 353 - cursor: pointer; 344 + .theme-toggle-btn { 345 + display: inline-flex; 346 + align-items: center; 347 + justify-content: center; 348 + } 349 + 350 + .theme-toggle-btn .theme-icon { 351 + width: 1.25rem; 352 + height: 1.25rem; 354 353 } 355 354 356 355 .delete-btn { 357 - background: var(--danger); 356 + background: transparent; 357 + border: none; 358 + color: var(--danger); 358 359 padding: 0.25rem 0.5rem; 359 360 font-size: 0.85rem; 361 + cursor: pointer; 362 + transition: all 0.2s ease; 363 + display: inline-flex; 364 + align-items: center; 365 + } 366 + 367 + .delete-btn:hover { 368 + color: var(--danger); 369 + } 370 + 371 + .delete-btn:hover .lucide { 372 + transform: scale(1.2); 360 373 } 361 374 362 375 .copy-btn { 363 - padding: 0.25rem 0.75rem; 364 - background: var(--button-primary); 365 - color: var(--btn-text); 376 + padding: 0.25rem 0.5rem; 377 + background: transparent; 378 + color: var(--secondary); 379 + border: none; 366 380 font-size: 0.85rem; 381 + cursor: pointer; 382 + transition: all 0.2s ease; 383 + display: inline-flex; 384 + align-items: center; 385 + } 386 + 387 + .copy-btn:hover { 388 + color: var(--primary); 389 + } 390 + 391 + .copy-btn:hover .lucide { 392 + transform: scale(1.2); 367 393 } 368 394 369 395 /* Cards */ ··· 414 440 } 415 441 416 442 .push-details { 443 + display: flex; 444 + align-items: center; 445 + gap: 1rem; 417 446 color: var(--border-dark); 418 447 font-size: 0.9rem; 419 448 margin-bottom: 0.75rem; ··· 441 470 gap: 0.5rem; 442 471 } 443 472 473 + /* Docker command component */ 474 + .docker-command { 475 + display: inline-flex; 476 + position: relative; 477 + align-items: center; 478 + gap: 0.5rem; 479 + background: var(--code-bg); 480 + border: 1px solid var(--border); 481 + border-radius: 6px; 482 + padding: 0.75rem; 483 + margin: 0.5rem 0; 484 + max-width: 100%; 485 + } 486 + 487 + .docker-command-icon { 488 + width: 1.25rem; 489 + height: 1.25rem; 490 + color: var(--secondary); 491 + flex-shrink: 0; 492 + } 493 + 494 + .docker-command-text { 495 + font-family: 'Monaco', 'Courier New', monospace; 496 + font-size: 0.85rem; 497 + color: var(--fg); 498 + flex: 0 1 auto; 499 + word-break: break-all; 500 + } 501 + 502 + .docker-command .copy-btn { 503 + position: absolute; 504 + right: 0.5rem; 505 + top: 50%; 506 + transform: translateY(-50%); 507 + background: linear-gradient(to right, transparent, var(--code-bg) 30%); 508 + padding: 0.5rem; 509 + padding-left: 1.5rem; 510 + border-radius: 4px; 511 + opacity: 0; 512 + visibility: hidden; 513 + transition: opacity 0.2s, visibility 0.2s; 514 + } 515 + 516 + .docker-command:hover .copy-btn { 517 + opacity: 1; 518 + visibility: visible; 519 + } 520 + 444 521 /* Digest tooltip on hover - using title attribute for native browser tooltip */ 445 522 .digest { 446 523 cursor: default; ··· 449 526 /* Digest copy button */ 450 527 .digest-copy-btn { 451 528 background: transparent; 452 - border: 1px solid var(--border); 529 + border: none; 453 530 color: var(--secondary); 454 531 padding: 0.1rem 0.4rem; 455 - font-size: 0.75rem; 456 - border-radius: 3px; 457 532 cursor: pointer; 458 - transition: all 0.2s; 533 + transition: all 0.2s ease; 459 534 display: inline-flex; 460 535 align-items: center; 461 536 } 462 537 463 538 .digest-copy-btn:hover { 464 - background: var(--hover-bg); 465 - border-color: var(--primary); 466 539 color: var(--primary); 467 540 } 468 541 542 + .digest-copy-btn:hover .lucide { 543 + transform: scale(1.2); 544 + } 545 + 546 + .digest-copy-btn .lucide { 547 + width: 0.875rem; 548 + height: 0.875rem; 549 + transition: transform 0.2s ease; 550 + } 551 + 552 + .delete-btn .lucide { 553 + width: 1rem; 554 + height: 1rem; 555 + transition: transform 0.2s ease; 556 + } 557 + 558 + .copy-btn .lucide { 559 + width: 1rem; 560 + height: 1rem; 561 + transition: transform 0.2s ease; 562 + } 563 + 469 564 .separator { 470 565 color: var(--border); 471 566 } ··· 538 633 .push-stat .star-icon { 539 634 color: var(--star); 540 635 font-size: 1rem; 636 + width: 1rem; 637 + height: 1rem; 638 + stroke: var(--star); 639 + fill: none; 640 + } 641 + 642 + .push-stat .star-icon.star-filled { 643 + fill: var(--star); 541 644 } 542 645 543 646 .push-stat .pull-icon { 544 647 color: var(--primary); 545 648 font-size: 1rem; 649 + width: 1rem; 650 + height: 1rem; 651 + stroke: var(--primary); 546 652 } 547 653 548 654 .push-stat .stat-count { ··· 859 965 margin: 1rem 0; 860 966 } 861 967 968 + .note a { 969 + color: var(--warning); 970 + text-decoration: underline; 971 + font-weight: 500; 972 + } 973 + 974 + .note a:hover { 975 + color: var(--primary); 976 + } 977 + 978 + .note a:visited { 979 + color: var(--warning); 980 + } 981 + 862 982 .success { 863 983 background: var(--success-bg); 864 984 border-left: 4px solid var(--success); 865 985 padding: 1rem; 866 986 margin: 1rem 0; 987 + display: flex; 988 + align-items: center; 989 + gap: 0.5rem; 990 + } 991 + 992 + .success .lucide { 993 + width: 1.25rem; 994 + height: 1.25rem; 995 + color: var(--success); 996 + stroke: var(--success); 997 + flex-shrink: 0; 867 998 } 868 999 869 1000 .error { ··· 1075 1206 background: var(--hover-bg); 1076 1207 } 1077 1208 1209 + /* Lucide icon base styles */ 1210 + .lucide { 1211 + display: inline-block; 1212 + width: 1em; 1213 + height: 1em; 1214 + vertical-align: middle; 1215 + stroke-width: 2; 1216 + transition: transform 0.2s ease; 1217 + } 1218 + 1219 + /* Star icon styles */ 1078 1220 .star-icon { 1079 1221 font-size: 1.25rem; 1080 1222 line-height: 1; 1081 1223 transition: transform 0.2s ease; 1082 - color:var(--star); 1224 + color: var(--star); 1225 + width: 1.25rem; 1226 + height: 1.25rem; 1227 + stroke: var(--star); 1228 + fill: none; 1229 + } 1230 + 1231 + .star-icon.star-filled { 1232 + fill: var(--star); 1083 1233 } 1084 1234 1085 1235 .star-btn:hover:not(:disabled) .star-icon { ··· 1193 1343 1194 1344 /* Offline manifest badge */ 1195 1345 .offline-badge { 1196 - display: inline-block; 1346 + display: inline-flex; 1347 + align-items: center; 1348 + gap: 0.35rem; 1197 1349 padding: 0.25rem 0.5rem; 1198 1350 background: var(--warning-bg); 1199 1351 color: var(--warning); ··· 1204 1356 margin-left: 0.5rem; 1205 1357 } 1206 1358 1359 + .offline-badge .lucide { 1360 + width: 0.875rem; 1361 + height: 0.875rem; 1362 + } 1363 + 1207 1364 /* Checking manifest badge (health check in progress) */ 1208 1365 .checking-badge { 1209 - display: inline-block; 1366 + display: inline-flex; 1367 + align-items: center; 1368 + gap: 0.35rem; 1210 1369 padding: 0.25rem 0.5rem; 1211 1370 background: #e3f2fd; 1212 1371 color: #1976d2; ··· 1215 1374 font-size: 0.85rem; 1216 1375 font-weight: 600; 1217 1376 margin-left: 0.5rem; 1377 + } 1378 + 1379 + .checking-badge .lucide { 1380 + width: 0.875rem; 1381 + height: 0.875rem; 1218 1382 } 1219 1383 1220 1384 /* Hide offline manifests by default */ ··· 1293 1457 font-size: 0.9rem; 1294 1458 font-weight: 500; 1295 1459 color: var(--secondary); 1460 + } 1461 + 1462 + .manifest-type .lucide { 1463 + width: 0.95rem; 1464 + height: 0.95rem; 1296 1465 } 1297 1466 1298 1467 .platform-count { ··· 1431 1600 .featured-stat .star-icon { 1432 1601 color: var(--star); 1433 1602 font-size: 1.1rem; 1603 + width: 1.1rem; 1604 + height: 1.1rem; 1605 + stroke: var(--star); 1606 + fill: none; 1607 + } 1608 + 1609 + .featured-stat .star-icon.star-filled { 1610 + fill: var(--star); 1434 1611 } 1435 1612 1436 1613 .featured-stat .pull-icon { 1437 1614 color: var(--primary); 1438 1615 font-size: 1.1rem; 1616 + width: 1.1rem; 1617 + height: 1.1rem; 1618 + stroke: var(--primary); 1439 1619 } 1440 1620 1441 1621 .featured-stat .stat-count { ··· 1599 1779 line-height: 1; 1600 1780 } 1601 1781 1782 + .benefit-icon .lucide { 1783 + width: 3rem; 1784 + height: 3rem; 1785 + stroke-width: 1.5; 1786 + color: var(--primary); 1787 + stroke: var(--primary); 1788 + } 1789 + 1602 1790 .benefit-card h3 { 1603 1791 font-size: 1.2rem; 1604 1792 margin-bottom: 0.75rem; ··· 1632 1820 margin: 1.5rem 0 0.5rem; 1633 1821 color: var(--border-dark); 1634 1822 font-size: 1.1rem; 1823 + } 1824 + 1825 + .install-section a { 1826 + color: var(--primary); 1827 + text-decoration: underline; 1828 + font-weight: 500; 1829 + } 1830 + 1831 + .install-section a:hover { 1832 + color: var(--primary-dark); 1833 + } 1834 + 1835 + .install-section a:visited { 1836 + color: var(--primary); 1635 1837 } 1636 1838 1637 1839 .code-block {
+40 -11
pkg/appview/static/js/app.js
··· 19 19 if (!themeBtn) return; 20 20 21 21 const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; 22 + const icon = themeBtn.querySelector('.theme-icon'); 23 + 24 + if (icon) { 25 + // In dark mode, show sun icon (to switch to light) 26 + // In light mode, show moon icon (to switch to dark) 27 + icon.setAttribute('data-lucide', currentTheme === 'dark' ? 'sun' : 'moon'); 28 + 29 + // Re-initialize Lucide icons 30 + if (typeof lucide !== 'undefined') { 31 + lucide.createIcons(); 32 + } 33 + } 34 + 22 35 themeBtn.setAttribute('aria-label', currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 23 36 } 24 37 ··· 26 39 function copyToClipboard(text) { 27 40 navigator.clipboard.writeText(text).then(() => { 28 41 // Show success feedback 29 - const btn = event.target; 30 - const originalText = btn.textContent; 31 - btn.textContent = '✓ Copied!'; 42 + const btn = event.target.closest('button'); 43 + const originalHTML = btn.innerHTML; 44 + btn.innerHTML = '<i data-lucide="check"></i> Copied!'; 45 + // Re-initialize Lucide icons for the new icon 46 + if (typeof lucide !== 'undefined') { 47 + lucide.createIcons(); 48 + } 32 49 setTimeout(() => { 33 - btn.textContent = originalText; 50 + btn.innerHTML = originalHTML; 51 + // Re-initialize Lucide icons to restore original icon 52 + if (typeof lucide !== 'undefined') { 53 + lucide.createIcons(); 54 + } 34 55 }, 2000); 35 56 }).catch(err => { 36 57 console.error('Failed to copy:', err); ··· 75 96 } 76 97 77 98 // Initial timestamp update 78 - document.addEventListener('DOMContentLoaded', updateTimestamps); 99 + document.addEventListener('DOMContentLoaded', () => { 100 + updateTimestamps(); 101 + updateThemeIcon(); 102 + }); 79 103 80 104 // Update timestamps after HTMX swaps 81 105 document.addEventListener('htmx:afterSwap', updateTimestamps); ··· 90 114 91 115 if (details.style.display === 'none') { 92 116 details.style.display = 'block'; 93 - btn.textContent = '▲'; 117 + btn.innerHTML = '<i data-lucide="chevron-up"></i>'; 94 118 } else { 95 119 details.style.display = 'none'; 96 - btn.textContent = '▼'; 120 + btn.innerHTML = '<i data-lucide="chevron-down"></i>'; 121 + } 122 + 123 + // Re-initialize Lucide icons 124 + if (typeof lucide !== 'undefined') { 125 + lucide.createIcons(); 97 126 } 98 127 } 99 128 ··· 154 183 155 184 try { 156 185 // Check current state 157 - const isStarred = starIcon.textContent === '★'; 186 + const isStarred = starIcon.classList.contains('star-filled'); 158 187 const method = isStarred ? 'DELETE' : 'POST'; 159 188 const url = `/api/stars/${handle}/${repository}`; 160 189 ··· 180 209 181 210 // Update UI optimistically 182 211 if (data.starred) { 183 - starIcon.textContent = '★'; 212 + starIcon.classList.add('star-filled'); 184 213 starBtn.classList.add('starred'); 185 214 // Optimistically increment count 186 215 const currentCount = parseInt(starCountEl.textContent) || 0; 187 216 starCountEl.textContent = currentCount + 1; 188 217 } else { 189 - starIcon.textContent = '☆'; 218 + starIcon.classList.remove('star-filled'); 190 219 starBtn.classList.remove('starred'); 191 220 // Optimistically decrement count 192 221 const currentCount = parseInt(starCountEl.textContent) || 0; ··· 229 258 const starData = await starResponse.json(); 230 259 console.log('Star status data:', starData); 231 260 if (starData.starred) { 232 - starIcon.textContent = '★'; 261 + starIcon.classList.add('star-filled'); 233 262 starBtn.classList.add('starred'); 234 263 } 235 264 } else {
+15
pkg/appview/templates/components/docker-command.html
··· 1 + {{ define "docker-command" }} 2 + {{/* 3 + Docker command component - displays a docker command with icon and copy button 4 + 5 + Expects: string - the docker command to display 6 + Usage: {{ template "docker-command" "docker pull atcr.io/alice/myapp:latest" }} 7 + */}} 8 + <div class="docker-command"> 9 + <i data-lucide="terminal" class="docker-command-icon"></i> 10 + <code class="docker-command-text">{{ . }}</code> 11 + <button class="copy-btn" onclick="copyToClipboard(this.getAttribute('data-cmd'))" data-cmd="{{ . }}"> 12 + <i data-lucide="copy"></i> 13 + </button> 14 + </div> 15 + {{ end }}
+14
pkg/appview/templates/components/head.html
··· 15 15 <!-- HTMX --> 16 16 <script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script> 17 17 18 + <!-- Lucide Icons --> 19 + <script src="https://unpkg.com/lucide@latest"></script> 20 + 18 21 <!-- App Scripts --> 19 22 <script src="/js/app.js"></script> 23 + <script> 24 + // Initialize Lucide icons after DOM is loaded 25 + document.addEventListener('DOMContentLoaded', () => { 26 + lucide.createIcons(); 27 + 28 + // Re-initialize icons after HTMX swaps content 29 + document.body.addEventListener('htmx:afterSwap', () => { 30 + lucide.createIcons(); 31 + }); 32 + }); 33 + </script> 20 34 {{ end }}
+3 -1
pkg/appview/templates/components/nav-theme-toggle.html
··· 1 1 {{ define "nav-theme-toggle" }} 2 - <button id="theme-toggle" onclick="toggleTheme()" class="btn-link theme-toggle-btn" aria-label="Toggle theme"></button> 2 + <button id="theme-toggle" onclick="toggleTheme()" class="btn-link theme-toggle-btn" aria-label="Toggle theme"> 3 + <i data-lucide="moon" class="theme-icon"></i> 4 + </button> 3 5 {{ end }}
+2 -2
pkg/appview/templates/components/repo-card.html
··· 31 31 </div> 32 32 <div class="featured-stats"> 33 33 <span class="featured-stat"> 34 - <span class="star-icon">★</span> 34 + <i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i> 35 35 <span class="stat-count">{{ .StarCount }}</span> 36 36 </span> 37 37 <span class="featured-stat"> 38 - <span class="pull-icon">↓</span> 38 + <i data-lucide="arrow-down-to-line" class="pull-icon"></i> 39 39 <span class="stat-count">{{ .PullCount }}</span> 40 40 </span> 41 41 </div>
+3 -3
pkg/appview/templates/pages/home.html
··· 39 39 <!-- Benefit Cards --> 40 40 <div class="hero-benefits"> 41 41 <div class="benefit-card"> 42 - <div class="benefit-icon">🐳</div> 42 + <div class="benefit-icon"><i data-lucide="ship"></i></div> 43 43 <h3>Works with Docker</h3> 44 44 <p>Use docker push & pull. No new tools to learn.</p> 45 45 </div> 46 46 <div class="benefit-card"> 47 - <div class="benefit-icon">⚓</div> 47 + <div class="benefit-icon"><i data-lucide="anchor"></i></div> 48 48 <h3>Your Data</h3> 49 49 <p>Join shared holds or captain your own storage.</p> 50 50 </div> 51 51 <div class="benefit-card"> 52 - <div class="benefit-icon">🧭</div> 52 + <div class="benefit-icon"><i data-lucide="compass"></i></div> 53 53 <h3>Discover Images</h3> 54 54 <p>Browse and star public container registries.</p> 55 55 </div>
+12 -27
pkg/appview/templates/pages/repository.html
··· 34 34 <div class="repo-info-row"> 35 35 <div class="repo-actions"> 36 36 <button class="star-btn{{ if .IsStarred }} starred{{ end }}" id="star-btn" onclick="toggleStar('{{ .Owner.Handle }}', '{{ .Repository.Name }}')"> 37 - <span class="star-icon" id="star-icon">{{ if .IsStarred }}★{{ else }}☆{{ end }}</span> 37 + <i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}" id="star-icon"></i> 38 38 <span class="star-count" id="star-count">{{ .StarCount }}</span> 39 39 </button> 40 40 </div> ··· 81 81 <h3>Pull this image</h3> 82 82 {{ if .Tags }} 83 83 {{ $firstTag := index .Tags 0 }} 84 - <div class="push-command"> 85 - <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}</code> 86 - <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}')"> 87 - Copy 88 - </button> 89 - </div> 84 + {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }} 90 85 {{ else }} 91 - <div class="push-command"> 92 - <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest</code> 93 - <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest')"> 94 - Copy 95 - </button> 96 - </div> 86 + {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }} 97 87 {{ end }} 98 88 </div> 99 89 </div> ··· 137 127 hx-confirm="Delete tag {{ .Tag.Tag }}?" 138 128 hx-target="#tag-{{ .Tag.Tag }}" 139 129 hx-swap="outerHTML"> 140 - 🗑️ 130 + <i data-lucide="trash-2"></i> 141 131 </button> 142 132 {{ end }} 143 133 </div> ··· 146 136 <div style="display: flex; justify-content: space-between; align-items: center;"> 147 137 <div class="digest-container"> 148 138 <code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code> 149 - <button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')">📋</button> 139 + <button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')"><i data-lucide="copy"></i></button> 150 140 </div> 151 141 {{ if .Platforms }} 152 142 <div class="platforms-inline"> ··· 157 147 {{ end }} 158 148 </div> 159 149 </div> 160 - <div class="push-command"> 161 - <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}</code> 162 - <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}')"> 163 - Copy 164 - </button> 165 - </div> 150 + {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }} 166 151 </div> 167 152 {{ end }} 168 153 </div> ··· 187 172 <div class="manifest-item-header"> 188 173 <div> 189 174 {{ if .IsManifestList }} 190 - <span class="manifest-type">📦 Multi-arch</span> 175 + <span class="manifest-type"><i data-lucide="package"></i> Multi-arch</span> 191 176 {{ else }} 192 - <span class="manifest-type">📄 Image</span> 177 + <span class="manifest-type"><i data-lucide="file-text"></i> Image</span> 193 178 {{ end }} 194 179 {{ if .Pending }} 195 180 <span class="checking-badge" 196 181 hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}" 197 182 hx-trigger="load delay:2s" 198 183 hx-swap="outerHTML"> 199 - 🔄 Checking... 184 + <i data-lucide="rotate-cw"></i> Checking... 200 185 </span> 201 186 {{ else if not .Reachable }} 202 - <span class="offline-badge">⚠️ Offline</span> 187 + <span class="offline-badge"><i data-lucide="alert-triangle"></i> Offline</span> 203 188 {{ end }} 204 189 <div class="digest-container"> 205 190 <code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code> 206 - <button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')">📋</button> 191 + <button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')"><i data-lucide="copy"></i></button> 207 192 </div> 208 193 </div> 209 194 <div style="display: flex; gap: 1rem; align-items: center;"> ··· 213 198 {{ if $.IsOwner }} 214 199 <button class="delete-btn" 215 200 onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')"> 216 - 🗑️ 201 + <i data-lucide="trash-2"></i> 217 202 </button> 218 203 {{ end }} 219 204 </div>
+21 -10
pkg/appview/templates/pages/settings.html
··· 65 65 <h3>First Time Setup</h3> 66 66 <ol> 67 67 <li>Install credential helper: 68 - <pre><code>brew install atcr-credential-helper</code></pre> 69 - (or download from releases) 68 + <pre><code>curl -fsSL atcr.io/static/install.sh | bash</code></pre> 70 69 </li> 71 70 <li>Configure Docker to use the helper. Add to <code>~/.docker/config.json</code>: 72 71 <pre><code>{ ··· 76 75 }</code></pre> 77 76 </li> 78 77 <li>Run any Docker command: 79 - <pre><code>docker pull {{ .RegistryURL }}/{{ .Profile.Handle }}/myimage</code></pre> 78 + {{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }} 80 79 </li> 81 80 <li>Browser will open for authorization - click Approve</li> 82 81 <li>Done! Device is automatically authorized</li> ··· 205 204 .devices-section .setup-instructions { 206 205 margin: 1rem 0; 207 206 padding: 1.5rem; 208 - background: #e3f2fd; 207 + background: var(--code-bg); 209 208 border-radius: 4px; 210 209 } 211 210 .devices-section .setup-instructions h3 { ··· 218 217 margin-bottom: 1rem; 219 218 } 220 219 .devices-section .setup-instructions pre { 221 - background: #263238; 222 - color: #aed581; 220 + background: var(--bg); 221 + color: var(--fg); 222 + border: 1px solid var(--border); 223 223 padding: 0.75rem; 224 224 border-radius: 4px; 225 225 overflow-x: auto; ··· 231 231 .devices-section .fallback-note { 232 232 margin-top: 1rem; 233 233 padding: 1rem; 234 - background: #fff3cd; 235 - border: 1px solid #ffc107; 234 + background: var(--warning-bg); 235 + border: 1px solid var(--warning); 236 236 border-radius: 4px; 237 237 } 238 + .devices-section .fallback-note a { 239 + color: var(--warning); 240 + text-decoration: underline; 241 + font-weight: 500; 242 + } 243 + .devices-section .fallback-note a:hover { 244 + color: var(--primary); 245 + } 246 + .devices-section .fallback-note a:visited { 247 + color: var(--warning); 248 + } 238 249 .devices-section table { 239 250 width: 100%; 240 251 border-collapse: collapse; ··· 244 255 .devices-section td { 245 256 padding: 0.75rem; 246 257 text-align: left; 247 - border-bottom: 1px solid #ddd; 258 + border-bottom: 1px solid var(--border); 248 259 } 249 260 .devices-section th { 250 - background: #f5f5f5; 261 + background: var(--code-bg); 251 262 font-weight: bold; 252 263 } 253 264 .devices-section .btn-danger {
+3 -4
pkg/appview/templates/partials/push-list.html
··· 17 17 </div> 18 18 <div class="push-stats"> 19 19 <span class="push-stat"> 20 - <span class="star-icon">★</span> 20 + <i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i> 21 21 <span class="stat-count">{{ .StarCount }}</span> 22 22 </span> 23 23 <span class="push-stat"> 24 - <span class="pull-icon">↓</span> 24 + <i data-lucide="arrow-down-to-line" class="pull-icon"></i> 25 25 <span class="stat-count">{{ .PullCount }}</span> 26 26 </span> 27 27 </div> ··· 35 35 <div class="push-details"> 36 36 <div class="digest-container"> 37 37 <code class="digest" title="{{ .Digest }}">{{ .Digest }}</code> 38 - <button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')">📋</button> 38 + <button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')"><i data-lucide="copy"></i></button> 39 39 </div> 40 - <span class="separator">•</span> 41 40 <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 42 41 {{ timeAgo .CreatedAt }} 43 42 </time>