A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

dark mode! fixes #1

+232 -81
+14 -2
pkg/appview/db/queries.go
··· 302 302 // GetUserByDID retrieves a user by DID 303 303 func GetUserByDID(db *sql.DB, did string) (*User, error) { 304 304 var user User 305 + var avatar sql.NullString 305 306 err := db.QueryRow(` 306 307 SELECT did, handle, pds_endpoint, avatar, last_seen 307 308 FROM users 308 309 WHERE did = ? 309 - `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.Avatar, &user.LastSeen) 310 + `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &user.LastSeen) 310 311 311 312 if err == sql.ErrNoRows { 312 313 return nil, nil 313 314 } 314 315 if err != nil { 315 316 return nil, err 317 + } 318 + 319 + // Handle NULL avatar 320 + if avatar.Valid { 321 + user.Avatar = avatar.String 316 322 } 317 323 318 324 return &user, nil ··· 321 327 // GetUserByHandle retrieves a user by handle 322 328 func GetUserByHandle(db *sql.DB, handle string) (*User, error) { 323 329 var user User 330 + var avatar sql.NullString 324 331 err := db.QueryRow(` 325 332 SELECT did, handle, pds_endpoint, avatar, last_seen 326 333 FROM users 327 334 WHERE handle = ? 328 - `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.Avatar, &user.LastSeen) 335 + `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &user.LastSeen) 329 336 330 337 if err == sql.ErrNoRows { 331 338 return nil, nil 332 339 } 333 340 if err != nil { 334 341 return nil, err 342 + } 343 + 344 + // Handle NULL avatar 345 + if avatar.Valid { 346 + user.Avatar = avatar.String 335 347 } 336 348 337 349 return &user, nil
+28
pkg/appview/jetstream/processor.go
··· 285 285 // This is called when Jetstream receives an identity event indicating a handle change. 286 286 // The identity cache is invalidated to ensure the next lookup uses the new handle, 287 287 // and the database is updated to reflect the change in the UI. 288 + // 289 + // Only processes events for users who already exist in our database (have ATCR activity). 288 290 func (p *Processor) ProcessIdentity(ctx context.Context, did string, newHandle string) error { 291 + // Check if user exists in our database - only update if they're an ATCR user 292 + user, err := db.GetUserByDID(p.db, did) 293 + if err != nil { 294 + return fmt.Errorf("failed to check user existence: %w", err) 295 + } 296 + 297 + // Skip if user doesn't exist - they don't have any ATCR activity (manifests, profiles, etc.) 298 + if user == nil { 299 + return nil 300 + } 301 + 289 302 // Update handle in database 290 303 if err := db.UpdateUserHandle(p.db, did, newHandle); err != nil { 291 304 slog.Warn("Failed to update user handle in database", ··· 308 321 slog.Info("Processed identity change event", 309 322 "component", "processor", 310 323 "did", did, 324 + "old_handle", user.Handle, 311 325 "new_handle", newHandle) 312 326 313 327 return nil ··· 326 340 // - If truly deactivated: Resolution fails and user won't appear in new queries 327 341 // 328 342 // This approach prevents data loss from PDS migrations while still handling deactivations. 343 + // 344 + // Only processes events for users who already exist in our database (have ATCR activity). 329 345 func (p *Processor) ProcessAccount(ctx context.Context, did string, active bool, status string) error { 330 346 // Only process deactivation events 331 347 if active || status != "deactivated" { 332 348 return nil 333 349 } 334 350 351 + // Check if user exists in our database - only update if they're an ATCR user 352 + user, err := db.GetUserByDID(p.db, did) 353 + if err != nil { 354 + return fmt.Errorf("failed to check user existence: %w", err) 355 + } 356 + 357 + // Skip if user doesn't exist - they don't have any ATCR activity 358 + if user == nil { 359 + return nil 360 + } 361 + 335 362 // Invalidate cached identity data to force re-resolution on next lookup 336 363 // This will discover if the account was migrated (new PDS) or truly deactivated (resolution fails) 337 364 if err := atproto.InvalidateIdentity(ctx, did); err != nil { ··· 345 372 slog.Info("Processed account deactivation event - cache invalidated", 346 373 "component", "processor", 347 374 "did", did, 375 + "handle", user.Handle, 348 376 "status", status) 349 377 350 378 return nil
+2 -13
pkg/appview/jetstream/worker.go
··· 443 443 } 444 444 445 445 identity := event.Identity 446 - slog.Info("Jetstream processing identity event", 447 - "did", identity.DID, 448 - "handle", identity.Handle, 449 - "seq", identity.Seq) 450 - 451 - // Process via shared processor 446 + // Process via shared processor (only ATCR users will be logged at Info level) 452 447 return w.processor.ProcessIdentity(context.Background(), identity.DID, identity.Handle) 453 448 } 454 449 ··· 459 454 } 460 455 461 456 account := event.Account 462 - slog.Info("Jetstream processing account event", 463 - "did", account.DID, 464 - "active", account.Active, 465 - "status", account.Status, 466 - "seq", account.Seq) 467 - 468 - // Process via shared processor 457 + // Process via shared processor (only ATCR users will be logged at Info level) 469 458 return w.processor.ProcessAccount(context.Background(), account.DID, account.Active, account.Status) 470 459 } 471 460
+148 -55
pkg/appview/static/css/style.css
··· 1 1 :root { 2 2 --primary: #0066cc; 3 + --button-primary: #0066cc; 3 4 --primary-dark: #0052a3; 4 5 --secondary: #6c757d; 5 6 --success: #28a745; ··· 16 17 --hover-bg: #f9f9f9; 17 18 --star: #fbbf24; 18 19 20 + /* Navbar colors - stay consistent in dark mode */ 21 + --navbar-bg: #1a1a1a; 22 + --navbar-fg: #ffffff; 23 + 24 + /* Button text color */ 25 + --btn-text: #ffffff; 26 + 27 + /* Theme toggle icon */ 28 + --theme-icon: '🌙'; 29 + 30 + /* Shadows */ 31 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); 32 + --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1); 33 + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); 34 + 35 + /* Metadata badge */ 36 + --metadata-badge-bg: #f0f0f0; 37 + --metadata-badge-text: var(--fg); 38 + 39 + /* Version badge */ 40 + --version-badge-bg: #f3e5f5; 41 + --version-badge-text: #7b1fa2; 42 + --version-badge-border: #ba68c8; 43 + 19 44 /* Hero section colors */ 20 45 --hero-bg-start: #f8f9fa; 21 46 --hero-bg-end: #e9ecef; ··· 28 53 --terminal-comment: #6a9955; 29 54 } 30 55 56 + [data-theme="dark"] { 57 + --primary: #60a5fa; 58 + --button-primary: #1d4ed8; 59 + --primary-dark: #1e40af; 60 + --secondary: #9ca3af; 61 + --success: #34d399; 62 + --success-bg: #064e3b; 63 + --warning: #fbbf24; 64 + --warning-bg: #78350f; 65 + --danger: #dc3545; 66 + --danger-bg: #7f1d1d; 67 + --bg: #2a2a2a; 68 + --fg: #e0e0e0; 69 + --border-dark: #9ca3af; 70 + --border: #404040; 71 + --code-bg: #1e1e1e; 72 + --hover-bg: #333333; 73 + --star: #fbbf24; 74 + 75 + /* Navbar colors - stay consistent (always black) */ 76 + --navbar-bg: #1a1a1a; 77 + --navbar-fg: #ffffff; 78 + 79 + /* Button text color */ 80 + --btn-text: #ffffff; 81 + 82 + /* Theme toggle icon */ 83 + --theme-icon: '☀️'; 84 + 85 + /* Shadows - lighter for dark backgrounds */ 86 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 87 + --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.4); 88 + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5); 89 + 90 + /* Metadata badge - darker in dark mode */ 91 + --metadata-badge-bg: #1e1e1e; 92 + --metadata-badge-text: #ffffff; 93 + 94 + /* Version badge - swapped colors with softer purple background */ 95 + --version-badge-bg: #9b59b6; 96 + --version-badge-text: #ffffff; 97 + --version-badge-border: #ba68c8; 98 + 99 + /* Hero section colors */ 100 + --hero-bg-start: #2d2d2d; 101 + --hero-bg-end: #1a1a1a; 102 + 103 + /* Terminal colors - keep similar since already dark */ 104 + --terminal-bg: #0d0d0d; 105 + --terminal-header-bg: #1a1a1a; 106 + --terminal-text: #d0d0d0; 107 + --terminal-prompt: #4ec9b0; 108 + --terminal-comment: #6a9955; 109 + } 110 + 31 111 * { 32 112 margin: 0; 33 113 padding: 0; ··· 42 122 } 43 123 44 124 .container { 45 - max-width: 1200px; 125 + max-width: 1920px; 46 126 margin: 0 auto; 47 127 padding: 20px; 48 128 } 49 129 50 130 /* Navigation */ 51 131 .navbar { 52 - background: var(--fg); 53 - color:var(--bg); 132 + background: var(--navbar-bg); 133 + color: var(--navbar-fg); 54 134 padding: 1rem 2rem; 55 135 display: flex; 56 136 justify-content: space-between; 57 137 align-items: center; 58 - box-shadow: 0 2px 4px rgba(0,0,0,0.1); 138 + box-shadow: var(--shadow-md); 59 139 } 60 140 61 141 .nav-brand a { 62 - color:var(--bg); 142 + color: var(--navbar-fg); 63 143 text-decoration: none; 64 144 font-size: 1.5rem; 65 145 font-weight: bold; ··· 90 170 } 91 171 92 172 .nav-links a { 93 - color:var(--fg); 173 + color: var(--navbar-fg); 94 174 text-decoration: none; 95 175 padding: 0.5rem 1rem; 96 176 } ··· 110 190 align-items: center; 111 191 gap: 0.5rem; 112 192 background: transparent; 113 - color:var(--bg); 193 + color: var(--navbar-fg); 114 194 border: none; 115 195 padding: 0.5rem; 116 196 cursor: pointer; ··· 133 213 width: 32px; 134 214 height: 32px; 135 215 border-radius: 50%; 136 - background: var(--primary); 216 + background: var(--button-primary); 137 217 display: flex; 138 218 align-items: center; 139 219 justify-content: center; ··· 153 233 width: 80px; 154 234 height: 80px; 155 235 border-radius: 50%; 156 - background: var(--primary); 236 + background: var(--button-primary); 157 237 display: flex; 158 238 align-items: center; 159 239 justify-content: center; 160 240 font-weight: bold; 161 241 font-size: 2rem; 162 242 text-transform: uppercase; 163 - color: var(--bg); 243 + color: var(--btn-text); 164 244 } 165 245 166 246 .user-profile { ··· 176 256 } 177 257 178 258 .user-handle { 179 - color: var(--bg); 259 + color: var(--navbar-fg); 180 260 font-size: 0.95rem; 181 261 } 182 262 ··· 195 275 background:var(--bg); 196 276 border: 1px solid var(--border); 197 277 border-radius: 8px; 198 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 278 + box-shadow: var(--shadow-lg); 199 279 min-width: 200px; 200 280 overflow: hidden; 201 281 z-index: 1000; ··· 237 317 /* Buttons */ 238 318 button, .btn, .btn-primary, .btn-secondary { 239 319 padding: 0.5rem 1rem; 240 - background: var(--primary); 241 - color:var(--bg); 320 + background: var(--button-primary); 321 + color: var(--btn-text); 242 322 border: none; 243 323 border-radius: 4px; 244 324 cursor: pointer; ··· 254 334 255 335 /* Override nav-links color for primary button */ 256 336 .nav-links .btn-primary { 257 - color: var(--bg); 337 + color: var(--btn-text); 258 338 } 259 339 260 340 .btn-secondary { ··· 263 343 264 344 .btn-link { 265 345 background: transparent; 266 - color:var(--bg); 267 - text-decoration: underline; 346 + color: var(--navbar-fg); 347 + text-decoration: none; 348 + } 349 + 350 + .theme-toggle-btn::before { 351 + content: var(--theme-icon); 352 + font-size: 1.2rem; 353 + cursor: pointer; 268 354 } 269 355 270 356 .delete-btn { ··· 275 361 276 362 .copy-btn { 277 363 padding: 0.25rem 0.75rem; 278 - background: var(--primary); 364 + background: var(--button-primary); 365 + color: var(--btn-text); 279 366 font-size: 0.85rem; 280 367 } 281 368 ··· 286 373 padding: 1rem; 287 374 margin-bottom: 1rem; 288 375 background:var(--bg); 289 - box-shadow: 0 1px 3px rgba(0,0,0,0.05); 376 + box-shadow: var(--shadow-sm); 290 377 } 291 378 292 379 .push-header { ··· 396 483 width: 48px; 397 484 height: 48px; 398 485 border-radius: 8px; 399 - background: var(--primary); 486 + background: var(--button-primary); 400 487 display: flex; 401 488 align-items: center; 402 489 justify-content: center; 403 490 font-weight: bold; 404 491 font-size: 1.5rem; 405 492 text-transform: uppercase; 406 - color: var(--bg); 493 + color: var(--btn-text); 407 494 flex-shrink: 0; 408 495 } 409 496 ··· 538 625 } 539 626 540 627 a.license-badge:hover { 541 - background: var(--primary); 542 - color: var(--bg); 543 - border-color: var(--primary); 628 + background: var(--button-primary); 629 + color: var(--btn-text); 630 + border-color: var(--button-primary); 544 631 transform: translateY(-1px); 545 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 632 + box-shadow: var(--shadow-md); 546 633 } 547 634 548 635 .version-badge { 549 - background: #f3e5f5; 550 - color: #7b1fa2; 551 - border: 1px solid #ba68c8; 636 + background: var(--version-badge-bg); 637 + color: var(--version-badge-text); 638 + border: 1px solid var(--version-badge-border); 552 639 } 553 640 554 641 .repo-description { ··· 633 720 border-radius: 8px; 634 721 padding: 1.5rem; 635 722 margin-bottom: 1.5rem; 636 - box-shadow: 0 1px 3px rgba(0,0,0,0.05); 723 + box-shadow: var(--shadow-sm); 637 724 } 638 725 639 726 .settings-section h2 { ··· 702 789 max-height: 80vh; 703 790 overflow-y: auto; 704 791 position: relative; 705 - box-shadow: 0 4px 6px rgba(0,0,0,0.1); 792 + box-shadow: var(--shadow-lg); 706 793 } 707 794 708 795 .modal-close { ··· 817 904 padding: 2rem; 818 905 border-radius: 8px; 819 906 border: 1px solid var(--border); 820 - box-shadow: 0 2px 4px rgba(0,0,0,0.05); 907 + box-shadow: var(--shadow-sm); 821 908 } 822 909 823 910 .login-form .form-group { ··· 877 964 border-radius: 8px; 878 965 padding: 2rem; 879 966 margin-bottom: 2rem; 880 - box-shadow: 0 1px 3px rgba(0,0,0,0.05); 967 + box-shadow: var(--shadow-sm); 881 968 } 882 969 883 970 .repo-hero { ··· 899 986 width: 80px; 900 987 height: 80px; 901 988 border-radius: 12px; 902 - background: var(--primary); 989 + background: var(--button-primary); 903 990 display: flex; 904 991 align-items: center; 905 992 justify-content: center; 906 993 font-weight: bold; 907 994 font-size: 2.5rem; 908 995 text-transform: uppercase; 909 - color: var(--bg); 996 + color: var(--btn-text); 910 997 flex-shrink: 0; 911 998 } 912 999 ··· 944 1031 margin: 0.5rem 0 0 0; 945 1032 } 946 1033 1034 + .repo-info-row { 1035 + display: flex; 1036 + gap: 2rem; 1037 + align-items: center; 1038 + margin-top: 1.5rem; 1039 + } 1040 + 947 1041 .repo-actions { 948 - margin-top: 1.5rem; 1042 + flex: 0 0 auto; 949 1043 } 950 1044 951 1045 .star-btn { ··· 1002 1096 gap: 1rem; 1003 1097 align-items: center; 1004 1098 flex-wrap: wrap; 1005 - margin-bottom: 1.5rem; 1006 - padding-top: 1rem; 1007 - border-top: 1px solid var(--border); 1099 + flex: 1; 1100 + justify-content: flex-end; 1008 1101 } 1009 1102 1010 1103 .metadata-badge { ··· 1044 1137 border-radius: 8px; 1045 1138 padding: 1.5rem; 1046 1139 margin-bottom: 2rem; 1047 - box-shadow: 0 1px 3px rgba(0,0,0,0.05); 1140 + box-shadow: var(--shadow-sm); 1048 1141 } 1049 1142 1050 1143 .repo-section h2 { ··· 1077 1170 .tag-name-large { 1078 1171 font-size: 1.2rem; 1079 1172 font-weight: 600; 1080 - color: var(--primary); 1173 + color: var(--fg); 1081 1174 } 1082 1175 1083 1176 .tag-timestamp { ··· 1166 1259 font-size: 0.75rem; 1167 1260 font-weight: 600; 1168 1261 border-radius: 12px; 1169 - background: var(--primary); 1170 - color: var(--bg); 1262 + background: var(--button-primary); 1263 + color: var(--btn-text); 1171 1264 white-space: nowrap; 1172 1265 margin-left: 0.5rem; 1173 1266 } ··· 1235 1328 border-radius: 8px; 1236 1329 padding: 1.5rem; 1237 1330 background: var(--bg); 1238 - box-shadow: 0 1px 3px rgba(0,0,0,0.05); 1331 + box-shadow: var(--shadow-sm); 1239 1332 transition: all 0.2s ease; 1240 1333 text-decoration: none; 1241 1334 color: var(--fg); ··· 1246 1339 } 1247 1340 1248 1341 .featured-card:hover { 1249 - box-shadow: 0 4px 8px rgba(0,0,0,0.1); 1342 + box-shadow: var(--shadow-md); 1250 1343 border-color: var(--primary); 1251 1344 transform: translateY(-2px); 1252 1345 } ··· 1270 1363 width: 48px; 1271 1364 height: 48px; 1272 1365 border-radius: 8px; 1273 - background: var(--primary); 1366 + background: var(--button-primary); 1274 1367 display: flex; 1275 1368 align-items: center; 1276 1369 justify-content: center; 1277 1370 font-weight: bold; 1278 1371 font-size: 1.5rem; 1279 1372 text-transform: uppercase; 1280 - color:var(--bg); 1373 + color: var(--btn-text); 1281 1374 flex-shrink: 0; 1282 1375 } 1283 1376 ··· 1383 1476 margin: 0 auto 2.5rem; 1384 1477 background: var(--terminal-bg); 1385 1478 border-radius: 8px; 1386 - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); 1479 + box-shadow: var(--shadow-lg); 1387 1480 overflow: hidden; 1388 1481 } 1389 1482 ··· 1453 1546 } 1454 1547 1455 1548 .btn-hero-primary { 1456 - background: var(--primary); 1457 - color: var(--bg); 1458 - border: 2px solid var(--primary); 1549 + background: var(--button-primary); 1550 + color: var(--btn-text); 1551 + border: 2px solid var(--button-primary); 1459 1552 } 1460 1553 1461 1554 .btn-hero-primary:hover { ··· 1468 1561 .btn-hero-secondary { 1469 1562 background: transparent; 1470 1563 color: var(--primary); 1471 - border: 2px solid var(--primary); 1564 + border: 2px solid var(--button-primary); 1472 1565 } 1473 1566 1474 1567 .btn-hero-secondary:hover { 1475 - background: var(--primary); 1476 - color: var(--bg); 1568 + background: var(--button-primary); 1569 + color: var(--btn-text); 1477 1570 transform: translateY(-2px); 1478 1571 } 1479 1572 ··· 1496 1589 1497 1590 .benefit-card:hover { 1498 1591 border-color: var(--primary); 1499 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 1592 + box-shadow: var(--shadow-md); 1500 1593 transform: translateY(-4px); 1501 1594 } 1502 1595 ··· 1697 1790 /* README and Repository Layout */ 1698 1791 .repo-content-layout { 1699 1792 display: grid; 1700 - grid-template-columns: 1fr 450px; 1793 + grid-template-columns: 7fr 3fr; 1701 1794 gap: 2rem; 1702 1795 margin-top: 2rem; 1703 1796 }
+24
pkg/appview/static/js/app.js
··· 1 + // Theme management 2 + // Load theme immediately to avoid flash 3 + (function() { 4 + const theme = localStorage.getItem('theme') || 'light'; 5 + document.documentElement.setAttribute('data-theme', theme); 6 + })(); 7 + 8 + function toggleTheme() { 9 + const html = document.documentElement; 10 + const currentTheme = html.getAttribute('data-theme') || 'light'; 11 + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; 12 + html.setAttribute('data-theme', newTheme); 13 + localStorage.setItem('theme', newTheme); 14 + updateThemeIcon(); 15 + } 16 + 17 + function updateThemeIcon() { 18 + const themeBtn = document.getElementById('theme-toggle'); 19 + if (!themeBtn) return; 20 + 21 + const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; 22 + themeBtn.setAttribute('aria-label', currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 23 + } 24 + 1 25 // Copy to clipboard 2 26 function copyToClipboard(text) { 3 27 navigator.clipboard.writeText(text).then(() => {
+1
pkg/appview/templates/components/nav.html
··· 11 11 </div> 12 12 13 13 <div class="nav-links"> 14 + <button id="theme-toggle" onclick="toggleTheme()" class="btn-link theme-toggle-btn" aria-label="Toggle theme"></button> 14 15 {{ if .User }} 15 16 <div class="user-dropdown"> 16 17 <button class="user-menu-btn" id="user-menu-btn" aria-expanded="false" aria-haspopup="true">
+15 -11
pkg/appview/templates/pages/repository.html
··· 30 30 </div> 31 31 </div> 32 32 33 - <!-- Star Button --> 34 - <div class="repo-actions"> 35 - <button class="star-btn{{ if .IsStarred }} starred{{ end }}" id="star-btn" onclick="toggleStar('{{ .Owner.Handle }}', '{{ .Repository.Name }}')"> 36 - <span class="star-icon" id="star-icon">{{ if .IsStarred }}★{{ else }}☆{{ end }}</span> 37 - <span class="star-count" id="star-count">{{ .StarCount }}</span> 38 - </button> 39 - </div> 33 + <!-- Star Button and Metadata Row --> 34 + <div class="repo-info-row"> 35 + <div class="repo-actions"> 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> 38 + <span class="star-count" id="star-count">{{ .StarCount }}</span> 39 + </button> 40 + </div> 40 41 41 - <!-- Metadata Section --> 42 - {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }} 43 - <div class="repo-metadata"> 42 + <!-- Metadata Section --> 43 + {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }} 44 + <div class="repo-metadata"> 44 45 {{ if .Repository.Version }} 45 46 <span class="metadata-badge version-badge" title="Version"> 46 47 {{ .Repository.Version }} ··· 69 70 Documentation 70 71 </a> 71 72 {{ end }} 73 + </div> 74 + {{ else }} 75 + <div class="repo-metadata"></div> 76 + {{ end }} 72 77 </div> 73 - {{ end }} 74 78 75 79 <!-- Pull Command --> 76 80 <div class="pull-command-section">