1/* Like / Dislike / Share / view+play counts for game pages. 2 Backed by Firebase Realtime Database. 3 4 Uses the Firebase COMPAT SDK (regular <script src="...">, global 5 `firebase` object) rather than the ES-module SDK on purpose: this 6 is a plain static site with no build step, and `type="module"` 7 scripts require the server to send the exact right JS MIME type or 8 the browser silently refuses to run them at all (bit some simple 9 static hosts / dev servers, e.g. a naive `python -m http.server` on 10 Windows, serve .js as text/plain and break module scripts). Compat 11 scripts don't have that requirement. Load order (see the game page 12 HTML, before this file): 13 firebase-app-compat.js, firebase-database-compat.js, firebase-auth-compat.js 14 15 Path: interactions/{slug} -> { likes: N, dislikes: N, views: N, 16 plays: N }, where slug is data-game on .vote-actions (likes/dislikes) 17 and .game-stats (views/plays). Matches the project's current 18 rules.json as-is (no schema restriction, so extra fields are fine): 19 20 "interactions": { 21 "$gameId": { 22 ".read": true, 23 ".write": "auth != null" 24 } 25 } 26 27 Since "auth != null" is required and this site has no login, every 28 visitor is signed in ANONYMOUSLY (signInAnonymously) purely so 29 auth.uid exists to satisfy that rule â enable "Anonymous" under 30 Firebase Console -> Authentication -> Sign-in method, or every vote 31 will fail with PERMISSION_DENIED. */ 32(function() { 33 const firebaseConfig = { 34 apiKey: "AIzaSyAJfK0aiOCEhH2LAClpES4Ch_zImPdQlHE", 35 authDomain: "gbkgames-like-dislike.firebaseapp.com", 36 // Confirmed live (returns 401, not 404, when probed unauthenticated) â 37 // this is the project's default Realtime Database instance. 38 databaseURL: "https://gbkgames-like-dislike-default-rtdb.firebaseio.com", 39 projectId: "gbkgames-like-dislike", 40 storageBucket: "gbkgames-like-dislike.firebasestorage.app", 41 messagingSenderId: "1024944812886", 42 appId: "1:1024944812886:web:97289064e2d84f2d435cf7", 43 measurementId: "G-N62D7NSC8L", 44 }; 45 46 if (typeof firebase === 'undefined') { 47 console.error('gamevotes: firebase compat SDK not loaded â check the ' + 48 'firebase-app-compat.js / firebase-database-compat.js / ' + 49 'firebase-auth-compat.js <script> tags are present before this file.'); 50 return; 51 } 52 53 firebase.initializeApp(firebaseConfig); 54 const db = firebase.database(); 55 const auth = firebase.auth(); 56 57 // Resolves once we have an authenticated (possibly anonymous) user. 58 // Votes can't write before this settles, since rules require auth != null. 59 const authReady = new Promise((resolve, reject) => { 60 auth.onAuthStateChanged((user) => { 61 if (user) { 62 resolve(user); 63 } else { 64 auth.signInAnonymously().catch((e) => { 65 console.error('gamevotes: anonymous sign-in failed â in the Firebase ' + 66 'console, open Authentication at least once (this initializes it ' + 67 'for the project) and enable the "Anonymous" sign-in provider.', e); 68 reject(e); 69 }); 70 } 71 }); 72 }); 73 74 // Without this, a broken/unconfigured auth setup leaves authReady 75 // pending forever, and any vote click's Promise chain hangs â the 76 // buttons stay disabled permanently instead of failing visibly. 77 function authReadyOrTimeout() { 78 return Promise.race([ 79 authReady, 80 new Promise((_, reject) => setTimeout(() => reject(new Error('sign-in timed out')), 8000)), 81 ]); 82 } 83 84 const store = { 85 getCounts(slug) { 86 return db.ref('interactions/' + slug).once('value').then(function(snap) { 87 const data = snap.val() || {}; 88 return { 89 like: data.likes || 0, 90 dislike: data.dislikes || 0, 91 views: data.views || 0, 92 plays: data.plays || 0, 93 }; 94 }); 95 }, 96 97 // Fire-and-forget +1 counters â no "undo" concept like votes have, 98 // so no prev/optimistic bookkeeping needed beyond the UI's own 99 // instant +1 (see initStats). 100 bumpCounter(slug, field) { 101 return authReadyOrTimeout().then(function() { 102 return db.ref('interactions/' + slug + '/' + field).transaction(function(n) { 103 return (n || 0) + 1; 104 }); 105 }).catch(function(e) { 106 console.error('gamevotes: failed to record ' + field + ' for', slug, e); 107 }); 108 }, 109 110 getUserChoice(slug) { 111 // Per-browser only â stops the same visitor from stacking votes, 112 // not a hard server-side guarantee (anonymous auth gives each 113 // browser a stable uid, but a new/cleared browser is a "new" voter). 114 return localStorage.getItem('voteChoice:' + slug); 115 }, 116 117 setUserChoice(slug, choice) { 118 if (choice) { 119 localStorage.setItem('voteChoice:' + slug, choice); 120 } else { 121 localStorage.removeItem('voteChoice:' + slug); 122 } 123 }, 124 125 // prev/choice are each "like", "dislike", or null. Takes prev as an 126 // explicit argument (rather than re-reading it from localStorage) 127 // so a second click fired before the first's network round trip 128 // finishes still nets out correctly. 129 applyVote(slug, prev, choice) { 130 return authReadyOrTimeout().then(function() { 131 return db.ref('interactions/' + slug).transaction(function(data) { 132 const next = { likes: (data && data.likes) || 0, dislikes: (data && data.dislikes) || 0 }; 133 if (prev === 'like') next.likes = Math.max(0, next.likes - 1);
134 if (prev === 'dislike') next.dislikes = Math.max(0, next.dislikes - 1); 135 if (choice === 'like') next.likes += 1; 136 if (choice === 'dislike') next.dislikes += 1; 137 return next; 138 }); 139 }).then(function(result) { 140 const data = (result.snapshot && result.snapshot.val()) || {}; 141 return { like: data.likes || 0, dislike: data.dislikes || 0 }; 142 }); 143 }, 144 }; 145 146 function formatCount(n) { 147 if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; 148 if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; 149 return String(n); 150 } 151 152 function render(container, counts, activeChoice) { 153 container.querySelectorAll('.vote-btn').forEach(function(btn) { 154 const type = btn.dataset.vote; 155 btn.classList.toggle('active', activeChoice === type); 156 const countEl = btn.querySelector('[data-count-for="' + type + '"]'); 157 if (countEl) countEl.textContent = formatCount(counts[type] || 0); 158 }); 159 } 160 161 function initVoteActions(container) { 162 const slug = container.dataset.game; 163 if (!slug) return; 164 165 // Show 0/0 immediately, then swap in the real counts once the 166 // database responds instead of leaving the buttons blank. 167 let lastKnownCounts = { like: 0, dislike: 0 }; 168 render(container, lastKnownCounts, store.getUserChoice(slug)); 169 store.getCounts(slug).then(function(counts) { 170 lastKnownCounts = counts; 171 render(container, counts, store.getUserChoice(slug)); 172 }).catch(function(e) { 173 console.error('gamevotes: failed to load counts for', slug, e); 174 }); 175 176 container.querySelectorAll('.vote-btn').forEach(function(btn) { 177 btn.addEventListener('click', function() { 178 const type = btn.dataset.vote; 179 const prev = store.getUserChoice(slug); 180 const next = prev === type ? null : type; // click again to retract 181 182 // Optimistic update: apply the count change and switch the 183 // active state instantly, don't wait on the network round 184 // trip. Firebase syncs in the background; if it turns out 185 // to have failed, silently resync from the real counts 186 // rather than blocking the click on it. 187 const optimistic = { like: lastKnownCounts.like, dislike: lastKnownCounts.dislike }; 188 if (prev) optimistic[prev] = Math.max(0, optimistic[prev] - 1); 189 if (next) optimistic[next] = optimistic[next] + 1; 190 lastKnownCounts = optimistic; 191 store.setUserChoice(slug, next); 192 render(container, optimistic, next); 193 194 store.applyVote(slug, prev, next).then(function(counts) { 195 lastKnownCounts = counts; 196 render(container, counts, store.getUserChoice(slug)); 197 }).catch(function(e) { 198 console.error('gamevotes: vote failed for', slug, e); 199 return store.getCounts(slug).then(function(counts) { 200 lastKnownCounts = counts; 201 render(container, counts, store.getUserChoice(slug)); 202 }); 203 }); 204 }); 205 }); 206 } 207 208 // Views/plays display next to the category badge. Views bump once 209 // per page load; plays bump when gameplayer-controls.js calls 210 // window.GBKInteractions.recordPlay() after the play-gate is clicked. 211 function initStats(container) { 212 const slug = container.dataset.game; 213 if (!slug) return; 214
215 function renderStats(counts) { 216 const viewsEl = container.querySelector('[data-count-for="views"]'); 217 const playsEl = container.querySelector('[data-count-for="plays"]'); 218 // dataset.raw holds the real number â textContent may be a 219 // formatted "1.2K", which isn't safe to parse back. 220 if (viewsEl) { 221 viewsEl.dataset.raw = counts.views || 0; 222 viewsEl.textContent = formatCount(counts.views || 0); 223 } 224 if (playsEl) { 225 playsEl.dataset.raw = counts.plays || 0; 226 playsEl.textContent = formatCount(counts.plays || 0); 227 } 228 } 229 230 store.getCounts(slug).then(function(counts) { 231 // Optimistic +1 for the view that's happening right now â the 232 // real bump call below lands in the background. 233 renderStats({ views: (counts.views || 0) + 1, plays: counts.plays || 0 }); 234 }).catch(function(e) { 235 console.error('gamevotes: failed to load stats for', slug, e); 236 }); 237 238 store.bumpCounter(slug, 'views'); 239 } 240 241 window.GBKInteractions = window.GBKInteractions || {}; 242 window.GBKInteractions.recordPlay = function(slug) { 243 const container = document.querySelector('.game-stats[data-game="' + slug + '"]'); 244 if (container) { 245 const playsEl = container.querySelector('[data-count-for="plays"]'); 246 if (playsEl) { 247 const raw = (parseInt(playsEl.dataset.raw, 10) || 0) + 1; 248 playsEl.dataset.raw = raw; 249 playsEl.textContent = formatCount(raw); 250 } 251 } 252 store.bumpCounter(slug, 'plays'); 253 }; 254 255 function initShareButtons() { 256 document.querySelectorAll('[data-action="share"]').forEach(function(btn) { 257 btn.addEventListener('click', function() { 258 const url = window.location.href; 259 const title = document.title; 260 if (navigator.share) { 261 navigator.share({ title, url }).catch(function() {}); 262 return; 263 } 264 if (navigator.clipboard) { 265 navigator.clipboard.writeText(url).then(function() { 266 const originalTip = btn.getAttribute('data-tip'); 267 const originalLabel = btn.getAttribute('aria-label'); 268 btn.setAttribute('data-tip', 'Link copied!'); 269 btn.setAttribute('aria-label', 'Link copied!'); 270 btn.classList.add('copied'); 271 setTimeout(function() { 272 btn.setAttribute('data-tip', originalTip); 273 btn.setAttribute('aria-label', originalLabel); 274 btn.classList.remove('copied'); 275 }, 1500); 276 }); 277 } 278 }); 279 }); 280 } 281 282 document.addEventListener('DOMContentLoaded', function() { 283 document.querySelectorAll('.vote-actions').forEach(initVoteActions); 284 document.querySelectorAll('.game-stats').forEach(initStats); 285 initShareButtons(); 286 }); 287})();
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.