1/** 2 * Follow Button Component 3 * 4 * Renders follow/unfollow buttons for bands, venues, and festivals across 5 * entity pages, event cards, and the fan dashboard. Manages optimistic state 6 * updates, batch state loading, and cross-button synchronization. 7 * 8 * Depends on globals from app.js: 9 * - API_BASE_URL 10 * - showToast(message, type, duration) 11 * - getValidAccessToken() 12 */ 13 14(function() { 15 'use strict'; 16 17 // ============ CONSTANTS ============ 18 19 var VALID_ENTITY_TYPES = ['band', 'venue', 'festival']; 20 21 // ============ CSS INJECTION ============ 22 23 var styleId = 'follow-btn-styles'; 24 if (!document.getElementById(styleId)) { 25 var style = document.createElement('style'); 26 style.id = styleId; 27 style.textContent = [ 28 '.follow-btn {', 29 ' display: inline-flex;', 30 ' align-items: center;', 31 ' gap: 6px;', 32 ' padding: 6px 14px;', 33 ' border-radius: 20px;', 34 ' font-size: 13px;', 35 ' font-weight: 600;', 36 ' cursor: pointer;', 37 ' transition: all 0.2s ease;', 38 ' border: 2px solid #4a7c59;', 39 ' background: transparent;', 40 ' color: #4a7c59;', 41 ' white-space: nowrap;', 42 ' line-height: 1;', 43 '}', 44 '.follow-btn:hover {', 45 ' background: rgba(74, 124, 89, 0.08);', 46 ' transform: scale(1.02);', 47 '}', 48 '.follow-btn--following {', 49 ' background: #4a7c59;', 50 ' color: #fff;', 51 ' border-color: #4a7c59;', 52 '}', 53 '.follow-btn--following:hover {', 54 ' background: #3d6a4b;', 55 ' border-color: #3d6a4b;', 56 ' transform: scale(1.02);', 57 '}', 58 '.follow-btn--loading {', 59 ' opacity: 0.6;', 60 ' pointer-events: none;', 61 '}', 62 '.follow-btn .follow-btn-icon {', 63 ' font-size: 14px;', 64 ' line-height: 1;', 65 '}', 66 '.follow-btn--popup {', 67 ' padding: 3px 10px;', 68 ' font-size: 11px;', 69 ' border-width: 1px;', 70 ' border-color: rgba(148, 163, 184, 0.4);', 71 ' color: #1e293b;', 72 ' background: rgba(255, 255, 255, 0.9);', 73 ' border-radius: 4px;', 74 ' gap: 4px;', 75 '}', 76 '.follow-btn--popup:hover {', 77 ' background: rgba(74, 124, 89, 0.1);', 78 ' transform: none;', 79 '}', 80 '.follow-btn--popup.follow-btn--following {', 81 ' background: rgba(74, 124, 89, 0.15);', 82 ' color: #166534;', 83 ' border-color: rgba(74, 124, 89, 0.4);', 84 '}', 85 '.follow-btn--popup.follow-btn--following:hover {', 86 ' background: rgba(74, 124, 89, 0.25);', 87 ' transform: none;', 88 '}', 89 '.follow-btn--popup .follow-btn-icon {', 90 ' font-size: 11px;', 91 '}', 92 '@keyframes slideUp {', 93 ' from { transform: translate(-50%, 20px); opacity: 0; }', 94 ' to { transform: translate(-50%, 0); opacity: 1; }', 95 '}', 96 // Branded unfollow confirmation dialog 97 '.unfollow-confirm-overlay {', 98 ' position: fixed;', 99 ' inset: 0;', 100 ' background: rgba(2, 6, 23, 0.6);', 101 ' display: flex;', 102 ' align-items: center;', 103 ' justify-content: center;', 104 ' z-index: 100000;', 105 ' padding: 1rem;', 106 ' animation: unfollowFade 0.15s ease;', 107 '}', 108 '@keyframes unfollowFade {', 109 ' from { opacity: 0; }', 110 ' to { opacity: 1; }', 111 '}', 112 '.unfollow-confirm-card {', 113 ' background: #1e293b;', 114 ' border: 1px solid rgba(74, 124, 89, 0.35);', 115 ' border-radius: 14px;', 116 ' padding: 1.5rem 1.5rem 1.25rem;',
117 ' max-width: 360px;', 118 ' width: 100%;', 119 ' box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);', 120 ' text-align: center;', 121 '}', 122 '.unfollow-confirm-title {', 123 ' margin: 0 0 0.5rem;', 124 ' font-size: 1.05rem;', 125 ' color: #f1f5f9;', 126 '}', 127 '.unfollow-confirm-text {', 128 ' margin: 0 0 1.25rem;', 129 ' font-size: 0.9rem;', 130 ' color: #94a3b8;', 131 ' line-height: 1.5;', 132 '}', 133 '.unfollow-confirm-actions {', 134 ' display: flex;', 135 ' gap: 0.75rem;', 136 ' justify-content: center;', 137 '}', 138 '.unfollow-confirm-actions button {', 139 ' flex: 1;', 140 ' padding: 0.6rem 1rem;', 141 ' border-radius: 8px;', 142 ' font-size: 0.9rem;', 143 ' font-weight: 600;', 144 ' cursor: pointer;', 145 ' transition: all 0.15s ease;', 146 '}', 147 '.unfollow-confirm-cancel {', 148 ' background: transparent;', 149 ' color: #cbd5e1;', 150 ' border: 1px solid rgba(148, 163, 184, 0.4);', 151 '}', 152 '.unfollow-confirm-cancel:hover {', 153 ' background: rgba(148, 163, 184, 0.12);', 154 '}', 155 '.unfollow-confirm-ok {', 156 ' background: #ef4444;', 157 ' color: #fff;', 158 ' border: 1px solid #ef4444;', 159 '}', 160 '.unfollow-confirm-ok:hover {', 161 ' background: #dc2626;', 162 ' border-color: #dc2626;', 163 '}', 164 '.unfollow-confirm-actions button:focus-visible {', 165 ' outline: 2px solid #38bdf8;', 166 ' outline-offset: 2px;', 167 '}' 168 ].join('\n'); 169 document.head.appendChild(style); 170 } 171 172 // ============ STATE TRACKING ============ 173 174 // Track all rendered buttons keyed by "entityType#entitySlug": [btnElement, ...] 175 var renderedButtons = {}; 176 177 // ============ HELPER FUNCTIONS ============ 178 179 function isLoggedIn() { 180 return !!localStorage.getItem('cognitoAccessToken'); 181 } 182 183 function getAuthHeaders() { 184 var token = localStorage.getItem('cognitoAccessToken'); 185 return { 186 'Content-Type': 'application/json', 187 'Authorization': 'Bearer ' + token 188 }; 189 } 190 191 function handleAuthError(response) { 192 if (response.status === 401) { 193 // Redirect to login â trigger the existing login modal 194 var loginBtn = document.getElementById('bandLoginBtn'); 195 if (loginBtn) { 196 loginBtn.click(); 197 } 198 return true; 199 } 200 return false; 201 } 202 203 /** 204 * Build a composite state key from entityType and entitySlug. 205 */ 206 function stateKey(entityType, entitySlug) { 207 return entityType + '#' + entitySlug; 208 } 209 210 /** 211 * Validate that entityType is one of the allowed values. 212 */ 213 function isValidEntityType(entityType) { 214 return VALID_ENTITY_TYPES.indexOf(entityType) !== -1; 215 } 216 217 // ============ RENDER FOLLOW BUTTON ============ 218 219 /** 220 * Render a follow button into the given container. 221 * @param {HTMLElement} container - DOM element to render the button into 222 * @param {string} entitySlug - The entity's slug identifier 223 * @param {string} entityName - The entity's display name 224 * @param {object} [options] - Optional configuration 225 * @param {string} [options.entityType] - 'band', 'venue', or 'festival' (default: 'band') 226 * @param {string} [options.size] - 'small' or 'default' 227 * @param {boolean} [options.initialState] - Initial following state if known 228 * @param {boolean} [options.popup] - Whether to use popup styling 229 */ 230 function renderFollowButton(container, entitySlug, entityName, options) { 231 options = options || {}; 232 var entityType = options.entityType || 'band'; 233 234 // Validate required params â do not render if missing 235 if (!container || !entitySlug || !entityName) return; 236 237 // Validate entityType â log warning and do not render if invalid 238 if (!isValidEntityType(entityType)) { 239 console.warn('[follow-button] Invalid entityType "' + entityType + '". Must be one of: ' + VALID_ENTITY_TYPES.join(', ')); 240 return; 241 } 242 243 var btn = document.createElement('button'); 244 btn.className = 'follow-btn'; 245 btn.setAttribute('data-entity-slug', entitySlug); 246 btn.setAttribute('data-entity-name', entityName); 247 btn.setAttribute('data-entity-type', entityType); 248 // Maintain backward compat data attributes for band type 249 btn.setAttribute('data-band-slug', entitySlug); 250 btn.setAttribute('data-band-name', entityName); 251 btn.setAttribute('aria-label', 'Follow ' + entityName); 252 btn.type = 'button'; 253 254 if (options.size === 'small') { 255 btn.style.fontSize = '12px'; 256 btn.style.padding = '4px 10px'; 257 } 258 259 if (options.popup) { 260 btn.classList.add('follow-btn--popup'); 261 } 262 263 // Set initial state â display "Follow" by default until state check completes 264 var isFollowing = options.initialState || false; 265 setButtonState(btn, isFollowing); 266 267 // Click handler 268 btn.addEventListener('click', function(e) { 269 e.preventDefault(); 270 e.stopPropagation(); 271 handleFollowClick(btn, entitySlug, entityName, entityType); 272 }); 273 274 container.appendChild(btn); 275 276 // Track this button by composite key 277 var key = stateKey(entityType, entitySlug); 278 if (!renderedButtons[key]) { 279 renderedButtons[key] = []; 280 } 281 renderedButtons[key].push(btn); 282 } 283 284 // ============ BUTTON STATE MANAGEMENT ============ 285 286 function setButtonState(btn, isFollowing) { 287 var entityName = btn.getAttribute('data-entity-name') || btn.getAttribute('data-band-name'); 288 if (isFollowing) { 289 btn.classList.add('follow-btn--following'); 290 btn.innerHTML = '<span class="follow-btn-icon">â</span><span>Following</span>'; 291 btn.setAttribute('aria-label', 'Unfollow ' + entityName); 292 btn.setAttribute('data-following', 'true'); 293 } else { 294 btn.classList.remove('follow-btn--following'); 295 btn.innerHTML = '<span class="follow-btn-icon">+</span><span>Follow</span>'; 296 btn.setAttribute('aria-label', 'Follow ' + entityName); 297 btn.setAttribute('data-following', 'false'); 298 } 299 } 300 301 function setButtonLoading(btn, loading) { 302 if (loading) { 303 btn.classList.add('follow-btn--loading'); 304 } else { 305 btn.classList.remove('follow-btn--loading'); 306 } 307 } 308 309 /** 310 * Update all rendered buttons for a given entity. 311 * @param {string} entitySlug - The entity's slug identifier 312 * @param {boolean} isFollowing - Whether the entity is now followed 313 * @param {string} [entityType] - Entity type (default: 'band') 314 */ 315 function updateFollowButtonState(entitySlug, isFollowing, entityType) { 316 entityType = entityType || 'band'; 317 var key = stateKey(entityType, entitySlug); 318 var buttons = renderedButtons[key]; 319 if (!buttons) return; 320 321 for (var i = 0; i < buttons.length; i++) { 322 setButtonState(buttons[i], isFollowing); 323 } 324 } 325 326 // ============ UNFOLLOW CONFIRMATION (branded) ============ 327 328 /** 329 * Show a branded confirmation dialog before unfollowing. 330 * Returns a Promise that resolves to true (confirmed) or false (cancelled). 331 * Replaces the native, unbranded window.confirm(). 332 */ 333 function showUnfollowConfirm(entityName) { 334 return new Promise(function(resolve) { 335 // Remove any existing dialog first 336 var existing = document.getElementById('unfollow-confirm-overlay'); 337 if (existing) existing.remove(); 338 339 var safeName = String(entityName || 'this').replace(/[<>&"]/g, function(c) { 340 return { '<': '<', '>': '>', '&': '&', '"': '"' }[c]; 341 }); 342
343 var overlay = document.createElement('div'); 344 overlay.id = 'unfollow-confirm-overlay'; 345 overlay.setAttribute('role', 'dialog'); 346 overlay.setAttribute('aria-modal', 'true'); 347 overlay.setAttribute('aria-labelledby', 'unfollow-confirm-title'); 348 overlay.className = 'unfollow-confirm-overlay'; 349 overlay.innerHTML = 350 '<div class="unfollow-confirm-card">' + 351 '<h3 id="unfollow-confirm-title" class="unfollow-confirm-title">Unfollow ' + safeName + '?</h3>' + 352 '<p class="unfollow-confirm-text">You\'ll stop getting alerts when they announce new shows.</p>' + 353 '<div class="unfollow-confirm-actions">' + 354 '<button type="button" class="unfollow-confirm-cancel">Stay following</button>' + 355 '<button type="button" class="unfollow-confirm-ok">Unfollow</button>' + 356 '</div>' + 357 '</div>'; 358 359 document.body.appendChild(overlay); 360 361 var cancelBtn = overlay.querySelector('.unfollow-confirm-cancel'); 362 var okBtn = overlay.querySelector('.unfollow-confirm-ok'); 363 var previousFocus = document.activeElement; 364 365 function cleanup(result) { 366 document.removeEventListener('keydown', onKeydown, true); 367 overlay.remove(); 368 if (previousFocus && previousFocus.focus) { 369 try { previousFocus.focus(); } catch (e) { /* ignore */ } 370 } 371 resolve(result); 372 } 373 374 function onKeydown(e) { 375 if (e.key === 'Escape') { 376 e.preventDefault(); 377 cleanup(false); 378 } else if (e.key === 'Tab') { 379 // Simple focus trap between the two buttons 380 e.preventDefault(); 381 if (document.activeElement === okBtn) { 382 cancelBtn.focus(); 383 } else { 384 okBtn.focus(); 385 } 386 } 387 } 388 389 cancelBtn.addEventListener('click', function() { cleanup(false); }); 390 okBtn.addEventListener('click', function() { cleanup(true); }); 391 overlay.addEventListener('click', function(e) { 392 if (e.target === overlay) cleanup(false); 393 }); 394 document.addEventListener('keydown', onKeydown, true); 395 396 // Focus the safe default (cancel) action 397 requestAnimationFrame(function() { cancelBtn.focus(); }); 398 }); 399 } 400 401 // ============ CLICK HANDLER ============ 402 403 async function handleFollowClick(btn, entitySlug, entityName, entityType) { 404 if (btn.classList.contains('follow-btn--loading')) return; 405 406 // If not logged in, show nudge and open the fan sign-up flow 407 if (!isLoggedIn()) { 408 showToast('Create a free account to follow ' + entityName + ' and get show alerts', 'info', 5000); 409 // Signal the auth modal to present fan-oriented messaging + register form 410 try { 411 sessionStorage.setItem('authIntent', 'fan-follow'); 412 sessionStorage.setItem('authIntentName', entityName || ''); 413 } catch (e) { /* storage may be unavailable */ } 414 var loginBtn = document.getElementById('bandLoginBtn'); 415 if (loginBtn) { 416 loginBtn.click(); 417 } 418 return; 419 } 420 421 var wasFollowing = btn.getAttribute('data-following') === 'true'; 422 var newState = !wasFollowing; 423 424 // Confirm before unfollowing with a branded dialog 425 if (wasFollowing) { 426 var confirmed = await showUnfollowConfirm(entityName); 427 if (!confirmed) { 428 return; 429 } 430 } 431 432 // Optimistic update â immediately show new state 433 updateFollowButtonState(entitySlug, newState, entityType); 434 setButtonLoading(btn, true); 435 436 try { 437 var url, body; 438 if (newState) { 439 // Follow 440 url = API_BASE_URL + '/follow/follow'; 441 body = JSON.stringify({ 442 entity_type: entityType, 443 entity_slug: entitySlug,
444 entity_name: entityName 445 }); 446 } else { 447 // Unfollow 448 url = API_BASE_URL + '/follow/unfollow'; 449 body = JSON.stringify({ 450 entity_type: entityType, 451 entity_slug: entitySlug 452 }); 453 } 454 455 var response = await fetch(url, { 456 method: 'POST', 457 headers: getAuthHeaders(), 458 body: body 459 }); 460 461 if (handleAuthError(response)) { 462 // Revert on auth error 463 updateFollowButtonState(entitySlug, wasFollowing, entityType); 464 setButtonLoading(btn, false); 465 return; 466 } 467 468 if (!response.ok) { 469 throw new Error('Request failed'); 470 } 471 472 // Success â state is already updated optimistically 473 setButtonLoading(btn, false); 474 475 // After a successful follow, check if user has push notifications enabled 476 if (newState) { 477 promptPushIfNeeded(entityName); 478 } 479 480 } catch (error) { 481 console.error('Follow action failed:', error); 482 // Revert to previous state 483 updateFollowButtonState(entitySlug, wasFollowing, entityType); 484 setButtonLoading(btn, false); 485 showToast('Something went wrong. Please try again.', 'error'); 486 } 487 } 488 489 // ============ PUSH NOTIFICATION PROMPT ============ 490 491 /** 492 * After a successful follow, prompt the user to enable push notifications 493 * if they haven't already. Only prompts once per session. 494 */ 495 var pushPromptShownThisSession = false; 496 497 async function promptPushIfNeeded(entityName) { 498 // Don't prompt if already shown this session 499 if (pushPromptShownThisSession) return; 500 501 // Don't prompt if browser doesn't support push 502 if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return; 503 504 // Don't prompt if already granted 505 if (Notification.permission === 'granted') { 506 // Check if subscription exists â if so, ensure it's linked to user_sub 507 try { 508 var registration = await navigator.serviceWorker.ready; 509 var subscription = await registration.pushManager.getSubscription(); 510 if (subscription) { 511 // Re-register with auth token to link user_sub (idempotent) 512 linkPushSubscriptionToUser(subscription); 513 } 514 } catch (e) { /* non-critical */ } 515 return; 516 } 517 518 // Don't prompt if explicitly denied 519 if (Notification.permission === 'denied') return; 520 521 // Don't prompt if user dismissed it before (localStorage flag) 522 if (localStorage.getItem('grassgigs_follow_push_dismissed')) return; 523 524 pushPromptShownThisSession = true; 525 526 // Show a non-intrusive prompt 527 showFollowPushPrompt(entityName); 528 } 529 530 function showFollowPushPrompt(entityName) { 531 // Remove existing prompt if any 532 var existing = document.getElementById('follow-push-prompt'); 533 if (existing) existing.remove(); 534 535 var prompt = document.createElement('div'); 536 prompt.id = 'follow-push-prompt'; 537 prompt.style.cssText = 'position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%); background: #1e293b; border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 12px; padding: 1rem 1.25rem; box-shadow: 0 10px 40px rgba(0,0,0,0.4); z-index: 9999; max-width: 360px; width: calc(100% - 2rem); text-align: center; animation: slideUp 0.3s ease;'; 538 539 prompt.innerHTML = 540 '<p style="color: #e2e8f0; margin: 0 0 0.75rem 0; font-size: 0.9rem; line-height: 1.4;">ð Want to get notified when <strong style="color: #10b981;">' + (entityName || 'this entity') + '</strong> posts new shows?</p>' + 541 '<div style="display: flex; gap: 0.5rem; justify-content: center;">' + 542 '<button id="follow-push-accept" style="background: #10b981; color: white; border: none; padding: 0.5rem 1rem; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background=\'#059669\'" onmouseout="this.style.background=\'#10b981\'">Enable Notifications</button>' + 543 '<button id="follow-push-dismiss" style="background: transparent; color: #94a3b8;
543 border: 1px solid rgba(148, 163, 184, 0.3); padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem; cursor: pointer;">Not now</button>' + 544 '</div>'; 545 546 document.body.appendChild(prompt); 547 548 // Bind events 549 document.getElementById('follow-push-accept').addEventListener('click', async function() { 550 this.disabled = true; 551 this.textContent = 'Enabling...'; 552 await registerFollowPushSubscription(); 553 prompt.remove(); 554 }); 555 556 document.getElementById('follow-push-dismiss').addEventListener('click', function() { 557 localStorage.setItem('grassgigs_follow_push_dismissed', '1'); 558 prompt.remove(); 559 }); 560 561 // Auto-dismiss after 15 seconds 562 setTimeout(function() { 563 if (document.getElementById('follow-push-prompt')) { 564 prompt.remove(); 565 } 566 }, 15000); 567 } 568 569 async function registerFollowPushSubscription() { 570 try { 571 var permission = await Notification.requestPermission(); 572 if (permission !== 'granted') { 573 showToast('Notifications were not enabled.', 'info'); 574 return; 575 } 576 577 var registration = await navigator.serviceWorker.ready; 578 // VAPID public key â same as in push-notifications.js 579 var vapidKey = (typeof VAPID_PUBLIC_KEY !== 'undefined') ? VAPID_PUBLIC_KEY : 'BNFtQ5rJYh5S1wTqK37uTWBbTbrbvrZSmZtiLLzCcfpAb8NUmaAxWP8oopQs6ie2_0_WZGsNv7NEdrLzlCGWwl0'; 580 581 // Convert VAPID key 582 function urlBase64ToUint8Array(base64String) { 583 var padding = '='.repeat((4 - base64String.length % 4) % 4); 584 var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); 585 var rawData = window.atob(base64); 586 var outputArray = new Uint8Array(rawData.length); 587 for (var i = 0; i < rawData.length; ++i) { 588 outputArray[i] = rawData.charCodeAt(i); 589 } 590 return outputArray; 591 } 592 593 var subscription = await registration.pushManager.subscribe({ 594 userVisibleOnly: true, 595 applicationServerKey: urlBase64ToUint8Array(vapidKey) 596 }); 597 598 await linkPushSubscriptionToUser(subscription); 599 showToast('Notifications enabled! You\'ll be alerted when followed bands post new shows.', 'success'); 600 601 } catch (err) { 602 console.error('Push registration failed:', err); 603 showToast('Could not enable notifications. Please try again.', 'error'); 604 } 605 } 606 607 async function linkPushSubscriptionToUser(subscription) { 608 try { 609 var keys = subscription.toJSON().keys || {}; 610 var body = { 611 endpoint: subscription.endpoint, 612 p256dh: keys.p256dh || '', 613 auth: keys.auth || '' 614 }; 615 616 // Send with auth token so backend links to user_sub 617 await fetch(API_BASE_URL + '/push/subscribe', { 618 method: 'POST', 619 headers: getAuthHeaders(), 620 body: JSON.stringify(body) 621 }); 622 } catch (err) { 623 console.error('Failed to link push subscription:', err); 624 } 625 } 626 627 // ============ BATCH STATE LOADING ============ 628 629 /** 630 * Load follow states for multiple entities in one request. 631 * Updates all rendered buttons for those entities. 632 * @param {string[]} entitySlugs - Array of entity slug identifiers 633 * @param {string} [entityType] - Entity type (default: 'band') 634 */ 635 async function batchLoadFollowStates(entitySlugs, entityType) { 636 entityType = entityType || 'band'; 637 if (!entitySlugs || entitySlugs.length === 0) return; 638 if (!isLoggedIn()) return; 639 640 // Deduplicate 641 var unique = []; 642 var seen = {}; 643 for (var i = 0; i < entitySlugs.length; i++) { 644 if (!seen[entitySlugs[i]]) { 645 seen[entitySlugs[i]] = true; 646 unique.push(entitySlugs[i]); 647 } 648 } 649 650 try { 651 var response = await fetch(API_BASE_URL + '/follow/state', { 652 method: 'POST', 653 headers: getAuthHeaders(), 654 body: JSON.stringify({ 655 entity_type: entityType, 656 entity_slugs: unique 657 }) 658 }); 659 660 if (handleAuthError(response)) return; 661 662 if (!response.ok) { 663 // On failure, default to not-following 664 return; 665 } 666 667 var data = await response.json(); 668 if (data.states) { 669 for (var slug in data.states) { 670 if (data.states.hasOwnProperty(slug)) { 671 updateFollowButtonState(slug, data.states[slug], entityType); 672 } 673 } 674 } 675 } catch (error) { 676 // On failure, keep buttons in default not-following state 677 console.error('Failed to load follow states:', error); 678 } 679 } 680 681 // ============ EXPOSE GLOBALLY ============ 682 683 window.renderFollowButton = renderFollowButton; 684 window.updateFollowButtonState = updateFollowButtonState; 685 window.batchLoadFollowStates = batchLoadFollowStates; 686 687})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.