PageSourceSearch

https://grassgigs.com/follow-button.js

js grassgigs.com collected 2026-10-02 00:30:11 UTC 27,343 bytes, 687 lines download raw bytes

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 { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[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.