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.

lucide icon pack. clean up some templates/css

+386 -96
+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>