PageSourceSearch

https://gbkgames.org/jsload/gamevotes.js

js gbkgames.org collected 2026-10-02 02:04:28 UTC 12,991 bytes, 287 lines download raw bytes

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.