PageSourceSearch

https://steam-snowball.vercel.app/script.js

js steam-snowball.vercel.app collected 2026-10-03 07:47:56 UTC 16,795 bytes, 479 lines download raw bytes

1document.addEventListener('DOMContentLoaded', () => {
2    const HLTB_CACHE_KEY = 'hltbCache:v1';
3    const HLTB_CACHE_LIMIT = 2000;
4    const loadBtn = document.getElementById('loadBtn');
5    const apiKeyInput = document.getElementById('apiKey');
6    const apiKeyPanel = document.getElementById('apiKeyPanel');
7    const revealApiKeyBtn = document.getElementById('revealApiKey');
8    const steamIdInput = document.getElementById('steamId');
9    const statusMsg = document.getElementById('statusMsg');
10    const resultsPanel = document.getElementById('resultsPanel');
11    const gamesBody = document.getElementById('gamesBody');
12    const statsDisplay = document.getElementById('statsDisplay');
13    const searchFilter = document.getElementById('searchFilter');
14    const unplayedOnlyFilter = document.getElementById('unplayedOnly');
15    const hideMissingHltbFilter = document.getElementById('hideMissingHltb');
16    const maxHoursEnabledFilter = document.getElementById('maxHoursEnabled');
17    const maxHoursFilter = document.getElementById('maxHoursFilter');
18
19    let gamesData = [];
20    let processingQueue = [];
21    let abortController = null;
22    let currentSortKey = 'playtime';
23    let currentSortDir = 'desc';
24    let hltbCache = loadHltbCache();
25
26    if (localStorage.getItem('steamApiKey')) apiKeyInput.value = localStorage.getItem('steamApiKey');
27    if (localStorage.getItem('steamApiKey') && apiKeyPanel && revealApiKeyBtn) {
28        apiKeyPanel.classList.remove('hidden');
29        revealApiKeyBtn.setAttribute('aria-expanded', 'true');
30    }
31    if (localStorage.getItem('steamId')) steamIdInput.value = localStorage.getItem('steamId');
32    if (localStorage.getItem('searchFilter')) searchFilter.value = localStorage.getItem('searchFilter');
33    if (localStorage.getItem('unplayedOnly') === 'true') unplayedOnlyFilter.checked = true;
34    if (localStorage.getItem('hideMissingHltb') === 'true') hideMissingHltbFilter.checked = true;
35    if (localStorage.getItem('maxHoursEnabled') === 'true') maxHoursEnabledFilter.checked = true;
36    if (localStorage.getItem('maxHoursFilter')) maxHoursFilter.value = localStorage.getItem('maxHoursFilter');
37    maxHoursFilter.disabled = !maxHoursEnabledFilter.checked;
38
39    const toggleApiKeyBtn = document.getElementById('toggleApiKey');
40    if (revealApiKeyBtn && apiKeyPanel) {
41        revealApiKeyBtn.addEventListener('click', () => {
42            const isHidden = apiKeyPanel.classList.contains('hidden');
43            apiKeyPanel.classList.toggle('hidden', !isHidden);
44            revealApiKeyBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
45
46            if (isHidden) {
47                apiKeyInput.focus();
48            }
49        });
50    }
51
52    if (toggleApiKeyBtn) {
53        toggleApiKeyBtn.addEventListener('click', () => {
54            const type = apiKeyInput.getAttribute('type') === 'password' ? 'text' : 'password';
55            const isVisible = type === 'text';
56
57            apiKeyInput.setAttribute('type', type);
58            toggleApiKeyBtn.style.color = isVisible ? '#38bdf8' : '';
59            toggleApiKeyBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false');
60            toggleApiKeyBtn.setAttribute('aria-label', isVisible ? 'Hide API key' : 'Show API key');
61        });
62    }
63
64    loadBtn.addEventListener('click', async () => {
65        const apiKey = apiKeyInput.value.trim();
66        const steamId = steamIdInput.value.trim();
67
68        if (!steamId) {
69            showStatus('Please enter a Steam ID.', 'error');
70            return;
71        }
72
73        localStorage.setItem('steamApiKey', apiKey);
74        localStorage.setItem('steamId', steamId);
75
76        if (abortController) abortController.abort();
77        abortController = new AbortController();
78        gamesData = [];
79        processingQueue = [];
80        gamesBody.innerHTML = '';
81        resultsPanel.classList.add('hidden');
82        showStatus('Fetching Steam library...', 'info');
83
84        try {
85            const response = await fetch('/api/fetch_library', {
86                method: 'POST',
87                headers: { 'Content-Type': 'application/json' },
88                body: JSON.stringify({ api_key: apiKey, steam_id: steamId }),
89                signal: abortController.signal
90            });
91
92            if (!response.ok) {
93                const data = await response.json();
94                throw new Error(data.error || 'Failed to fetch library.');
95            }
96
97            const data = await response.json();
98            gamesData = data.games.map(game => ({
99                appid: game.appid,
100                name: game.name,
101                playtime_forever: game.playtime_forever || 0,
102                img_icon_url: game.img_icon_url,
103                hltb: game.hltb || null,
104                source: game.source
105            }));
106
107            hydrateGamesFromLocalCache(gamesData);
108            persistServerCache(gamesData);
109
110            if (data.errors && data.errors.length > 0) {
111                const errorMsg = data.errors.join(' | ');
112
113                if (gamesData.length > 0) {
114                    showStatus(`Loaded ${gamesData.length} games. Warning: ${errorMsg}`, 'error');
115                } else {
116                    showStatus(`Failed: ${errorMsg}`, 'error');
117                    return;
118                }
119            } else {
120                showStatus(`Found ${gamesData.length} games. Fetching completion times...`, 'success');
121            }
122
123            applyCurrentView();
124            resultsPanel.classList.remove('hidden');
125
126            processingQueue = gamesData.filter(game => !game.hltb);
127
128            if (processingQueue.length > 0) {
129                showStatus(`Found ${gamesData.length} games. Fetching data for ${processingQueue.length} items...`, 'info');
130                processQueue();
131            } else {
132                showStatus(`Loaded ${gamesData.length} games from cache!`, 'success');
133            }
134        } catch (error) {
135            if (error.name === 'AbortError') return;
136            showStatus(error.message, 'error');
137        }
138    });
139
140    async function processQueue() {
141        if (processingQueue.length === 0) {
142            showStatus('Finished fetching data.', 'success');
143            return;
144        }
145
146        const batchSize = 50;
147        const batch = processingQueue.splice(0, batchSize);
148
149        const promises = batch.map(async (game) => {
150            try {
151                const response = await fetch('/api/get_game_time', {
152                    method: 'POST',
153                    headers: { 'Content-Type': 'application/json' },
154                    body: JSON.stringify({ game_name: game.name })
155                });
156
157                const targetGame = gamesData.find(g => g.appid === game.appid);
158                if (!targetGame) {
159                    return;
160                }
161
162                if (response.ok) {
163                    targetGame.hltb = await response.json();
164                } else {
165                    targetGame.hltb = { not_found: true };
166                }
167
168                saveGameTimeToLocalCache(targetGame.name, targetGame.hltb);
169
170                applyCurrentView();
171            } catch (e) {
172                console.error(`Error fetching for ${game.name}`, e);
173            }
174        });
175
176        await Promise.all(promises);
177
178        const remaining = processingQueue.length;
179        const total = gamesData.length;
180        showStatus(`Fetching completion times... (${total - remaining}/${total})`, 'info');
181
182        if (processingQueue.length > 0) {
183            setTimeout(processQueue, 10);
184        } else {
185            showStatus(`Done! Loaded ${total} games.`, 'success');
186        }
187    }
188
189    function renderTable(games) {
190        gamesBody.innerHTML = '';
191        games.forEach(game => {
192            const tr = document.createElement('tr');
193            tr.id = `row-${game.appid}`;
194            tr.innerHTML = getRowHTML(game);
195            gamesBody.appendChild(tr);
196        });
197    }
198
199    function getRowHTML(game) {
200        const iconUrl = game.img_icon_url
201            ? `http://media.steampowered.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg`
202            : 'https://via.placeholder.com/32';
203
204        const playtimeHours = ((game.playtime_forever || 0) / 60).toFixed(1);
205
206        let main = '<div class="loading-pulse"></div>';
207        let extra = '<div class="loading-pulse"></div>';
208        let compl = '<div class="loading-pulse"></div>';
209
210        if (game.hltb) {
211            if (game.hltb.not_found) {
212                main = '-';
213                extra = '-';
214                compl = '-';
215            } else {
216                main = formatTime(game.hltb.main_story);
217                extra = formatTime(game.hltb.main_extra);
218                compl = formatTime(game.hltb.completionist);
219            }
220        }
221
222        return `
223            <td class="game-cell">
224                <img src="${iconUrl}" class="game-icon" alt="">
225                <div class="game-info">
226                    <span>${escapeHtml(game.name)}</span>
227                </div>
228            </td>
229            <td>${playtimeHours}h</td>
230            <td>${main}</td>
231            <td>${extra}</td>
232            <td>${compl}</td>
233        `;
234    }
235
236    function formatTime(val) {
237        if (!val) return '-';
238        return `${val}h`;
239    }
240
241    function escapeHtml(text) {
242        if (!text) return '';
243        return text.replace(/&/g, '&amp;')
244            .replace(/</g, '&lt;')
245            .replace(/>/g, '&gt;')
246            .replace(/"/g, '&quot;')
247            .replace(/'/g, '&#039;');
248    }
249
250    function showStatus(msg, type) {
251        statusMsg.setAttribute('role', type === 'error' ? 'alert' : 'status');
252        statusMsg.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
253        statusMsg.textContent = msg;
254        statusMsg.style.color = type === 'error' ? '#ef4444' : '#38bdf8';
255    }
256
257    function getFilteredGames() {
258        const term = searchFilter.value.trim().toLowerCase();
259        const unplayedOnly = unplayedOnlyFilter.checked;
260        const hideMissingHltb = hideMissingHltbFilter.checked;
261        const maxHoursEnabled = maxHoursEnabledFilter.checked;
262        const maxHours = Number(maxHoursFilter.value);
263
264        return gamesData.filter(game => {
265            const playtimeHours = (game.playtime_forever || 0) / 60;
266
267            if (term && !game.name.toLowerCase().includes(term)) {
268                return false;
269            }
270
271            if (unplayedOnly && playtimeHours > 0) {
272                return false;
273            }
274
275            if (hideMissingHltb && (!game.hltb || game.hltb.not_found)) {
276                return false;
277            }
278
279            if (maxHoursEnabled) {
280                if (!Number.isFinite(maxHours) || maxHours < 0) {
281                    return false;
282                }
283
284                if (playtimeHours >= maxHours) {
285                    return false;
286                }
287            }
288
289            return true;
290        });
291    }
292
293    function updateStats() {
294        const visibleCount = getFilteredGames().length;
295        statsDisplay.textContent = `Showing ${visibleCount} of ${gamesData.length} games`;
296    }
297
298    function applyCurrentView() {
299        sortData(currentSortKey, currentSortDir, false);
300        renderTable(getFilteredGames());
301        updateStats();
302    }
303
304    searchFilter.addEventListener('input', () => {
305        localStorage.setItem('searchFilter', searchFilter.value);
306        applyCurrentView();
307    });
308
309    unplayedOnlyFilter.addEventListener('change', () => {
310        localStorage.setItem('unplayedOnly', String(unplayedOnlyFilter.checked));
311        applyCurrentView();
312    });
313
314    hideMissingHltbFilter.addEventListener('change', () => {
315        localStorage.setItem('hideMissingHltb', String(hideMissingHltbFilter.checked));
316        applyCurrentView();
317    });
318
319    maxHoursEnabledFilter.addEventListener('change', () => {
320        localStorage.setItem('maxHoursEnabled', String(maxHoursEnabledFilter.checked));
321        maxHoursFilter.disabled = !maxHoursEnabledFilter.checked;
322        applyCurrentView();
323    });
324
325    maxHoursFilter.addEventListener('input', () => {
326        localStorage.setItem('maxHoursFilter', maxHoursFilter.value);
327        applyCurrentView();
328    });
329
330    document.querySelectorAll('.sort-btn').forEach(button => {
331        button.addEventListener('click', () => {
332            const sortKey = button.dataset.sort;
333            const header = button.closest('th');
334            const currentDir = header.dataset.dir === 'asc' ? 'desc' : 'asc';
335
336            document.querySelectorAll('th.sortable').forEach(h => {
337                h.dataset.dir = '';
338                h.setAttribute('aria-sort', 'none');
339                h.querySelector('.arrow').textContent = '';
340            });
341
342            header.dataset.dir = currentDir;
343            header.setAttribute('aria-sort', currentDir === 'asc' ? 'ascending' : 'descending');
344            header.querySelector('.arrow').innerHTML = currentDir === 'asc' ? '&#9650;' : '&#9660;';
345
346            sortData(sortKey, currentDir);
347        });
348    });
349
350    const defaultSortButton = document.querySelector('.sort-btn[data-sort="playtime"]');
351    const defaultSortHeader = defaultSortButton ? defaultSortButton.closest('th') : null;
352    if (defaultSortHeader) {
353        defaultSortHeader.dataset.dir = currentSortDir;
354        defaultSortHeader.setAttribute('aria-sort', 'descending');
355        defaultSortHeader.querySelector('.arrow').innerHTML = '&#9660;';
356    }
357
358    function sortData(key, dir, rerender = true) {
359        currentSortKey = key;
360        currentSortDir = dir;
361
362        gamesData.sort((a, b) => {
363            let valA;
364            let valB;
365
366            if (key === 'playtime') {
367                valA = a.playtime_forever;
368                valB = b.playtime_forever;
369            } else {
370                const mapKey = key === 'main' ? 'main_story' : (key === 'extra' ? 'main_extra' : 'completionist');
371
372                valA = a.hltb && !a.hltb.not_found ? parseTime(a.hltb[mapKey]) : -1;
373                valB = b.hltb && !b.hltb.not_found ? parseTime(b.hltb[mapKey]) : -1;
374            }
375
376            if (valA < valB) return dir === 'asc' ? -1 : 1;
377            if (valA > valB) return dir === 'asc' ? 1 : -1;
378            return 0;
379        });
380
381        if (rerender) {
382            renderTable(getFilteredGames());
383            updateStats();
384        }
385    }
386
387    function parseTime(val) {
388        return Number(val) || 0;
389    }
390
391    function loadHltbCache() {
392        try {
393            const raw = localStorage.getItem(HLTB_CACHE_KEY);
394            if (!raw) {
395                return {};
396            }
397
398            const parsed = JSON.parse(raw);
399            return parsed && typeof parsed === 'object' ? parsed : {};
400        } catch (error) {
401            console.warn('Failed to load HLTB cache from localStorage', error);
402            return {};
403        }
404    }
405
406    function saveHltbCache() {
407        try {
408            hltbCache = pruneHltbCache(hltbCache);
409            localStorage.setItem(HLTB_CACHE_KEY, JSON.stringify(hltbCache));
410        } catch (error) {
411            console.warn('Failed to save HLTB cache to localStorage', error);
412        }
413    }
414
415    function pruneHltbCache(cache) {
416        const entries = Object.entries(cache);
417        if (entries.length <= HLTB_CACHE_LIMIT) {
418            return cache;
419        }
420
421        entries.sort((a, b) => (a[1].cachedAt || 0) - (b[1].cachedAt || 0));
422        const trimmedEntries = entries.slice(entries.length - HLTB_CACHE_LIMIT);
423        return Object.fromEntries(trimmedEntries);
424    }
425
426    function getCachedGameTime(gameName) {
427        if (!gameName) {
428            return null;
429        }
430
431        const entry = hltbCache[gameName];
432        return entry ? entry.data : null;
433    }
434
435    function saveGameTimeToLocalCache(gameName, gameTimeData) {
436        if (!gameName || !gameTimeData) {
437            return;
438        }
439
440        hltbCache[gameName] = {
441            cachedAt: Date.now(),
442            data: gameTimeData
443        };
444        saveHltbCache();
445    }
446
447    function hydrateGamesFromLocalCache(games) {
448        games.forEach(game => {
449            if (game.hltb) {
450                return;
451            }
452
453            const cachedGameTime = getCachedGameTime(game.name);
454            if (cachedGameTime) {
455                game.hltb = cachedGameTime;
456            }
457        });
458    }
459
460    function persistServerCache(games) {
461        let hasUpdates = false;
462
463        games.forEach(game => {
464            if (!game.hltb) {
465                return;
466            }
467
468            hltbCache[game.name] = {
469                cachedAt: Date.now(),
470                data: game.hltb
471            };
472            hasUpdates = true;
473        });
474
475        if (hasUpdates) {
476            saveHltbCache();
477        }
478    }
479});

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.