Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:38:08 +02:00
commit c737a37f71
531 files changed
+119270

No files matched your search

+481
View File
@@ -0,0 +1,481 @@
// ── Games page ────────────────────────────────────────────────────────────────
async function loadGames() {
try {
const library = await window.pork.listLibraryTitles({ limit: 1000 });
state.games = window.chimeraLibraryModel.items(library);
window.pork.shadowMountStatus?.().then(result => {
const byGame = new Map();
for (const entry of (result.model?.entries || [])) {
if (!entry.titleId) continue;
const current = byGame.get(entry.titleId) || [];
current.push(entry);
byGame.set(entry.titleId, current);
}
state.games = state.games.map(game => {
const matches = byGame.get(game.game_id) || [];
if (!matches.length) return game;
return { ...game, shadow_mount: {
state: matches.some(entry => entry.state === 'installed') ? 'installed' : matches[0].state,
sources: matches.length,
experimental: matches.some(entry => entry.format?.support === 'experimental'),
formats: [...new Set(matches.map(entry => entry.format?.id).filter(Boolean))],
} };
});
renderGames();
}).catch(() => {});
// Refresh hash summaries (fire-and-forget so badges update when ready)
window.pork.hashGameSummary().then(summaries => {
_populateHashMaps(summaries);
renderGames();
}).catch(() => {});
renderGames();
await refreshFirmwareFilters();
await refreshConvQueuePanel();
const conversionQueue = $('conv-queue-panel');
const gamesPage = $('page-games');
if (conversionQueue && gamesPage) gamesPage.append(conversionQueue);
await refreshGameCatalogHealth();
} catch (e) {
setStatus(e.message, 'error');
}
}
async function refreshGameCatalogHealth() {
if (!window.pork.gameCatalogHealth || !$('game-catalog-health')) return;
try {
const health = await window.pork.gameCatalogHealth();
$('catalog-health-complete').textContent = `${Number(health.complete || 0)} / ${Number(health.total || 0)}`;
$('catalog-health-missing').textContent = String(Number(health.missing_metadata || 0));
$('catalog-health-artwork').textContent = String(Number(health.missing_artwork || 0));
const scan = health.scan || {};
$('catalog-health-scan').textContent = scan.running ? 'Scanning…' : (scan.lastError ? 'Scan issue' : 'Automatic');
$('catalog-health-scan-detail').textContent = scan.lastError
? String(scan.lastError)
: (scan.lastCompleted
? window.tf('games.health.lastScan', 'last scan {date}', { date: new Date(scan.lastCompleted).toLocaleString(window.i18n?.current || 'en') })
: window.t('games.health.interval', 'source check every 15 min'));
} catch (_) {}
}
window.pork.on('local:scan:started', refreshGameCatalogHealth);
window.pork.on('local:scan:complete', () => refreshGameCatalogHealth());
window.pork.on('prospero:queue:done', () => refreshGameCatalogHealth());
function gamePlatform(game) {
const explicit = String(game?.platform || '').toLowerCase();
if (explicit === 'ps4' || explicit === 'ps5') return explicit;
const identity = [
game?.game_id,
game?.content_id,
game?.prospero_content_id,
].filter(Boolean).join(' ').toUpperCase();
if (/CUSA\d{5}/.test(identity)) return 'ps4';
if (/PPSA\d{5}/.test(identity)) return 'ps5';
return 'unknown';
}
function versionedArtworkUrl(url, fetchedAt) {
const source = String(url || '');
if (!source.startsWith('/pork-cache/')) return source;
const revision = Number(fetchedAt || 0);
return revision > 0 ? `${source}?v=${Math.trunc(revision)}` : source;
}
function disconnectedDriveLabels(game) {
const labels = [];
for (const copy of Array.isArray(game?.localCopies) ? game.localCopies : []) {
const device = copy.device || copy.storageDevice || copy.volume?.device || {};
const state = String(device.availabilityState || copy.volume?.availabilityState || copy.availabilityState || '').toLowerCase();
if (!['unavailable', 'disconnected', 'offline', 'missing'].includes(state)) continue;
labels.push(device.friendlyLabel || device.displayName || device.label || device.stableFingerprint || copy.volume?.stableVolumeKey || 'external drive');
}
return [...new Set(labels.map(String))];
}
function renderGamePlatformFilters() {
const counts = { all: state.games.length, ps4: 0, ps5: 0, unknown: 0 };
for (const game of state.games) counts[gamePlatform(game)] += 1;
for (const platform of Object.keys(counts)) {
const count = $(`games-platform-count-${platform}`);
if (count) count.textContent = String(counts[platform]);
}
document.querySelectorAll('[data-game-platform]').forEach(button => {
const platform = button.dataset.gamePlatform;
const active = platform === state.platformFilter;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
if (platform === 'unknown') button.hidden = counts.unknown === 0 && !active;
});
}
function getFilteredGames() {
return state.games.filter(g => {
const q = state.search.toLowerCase();
if (q && !(g.title || '').toLowerCase().includes(q) && !(g.game_id || '').toLowerCase().includes(q) &&
!(g.prospero_name || '').toLowerCase().includes(q)) return false;
if (state.platformFilter !== 'all' && gamePlatform(g) !== state.platformFilter) return false;
// Status and firmware are independent filter dimensions — compute status as a
// boolean so both can compose (previously the status branches returned early,
// silently disabling the firmware filter).
let statusOk = true;
if (state.filter === 'installed') statusOk = window.chimeraLibraryModel.isInstalled(g);
else if (state.filter === 'backed_up') statusOk = window.chimeraLibraryModel.hasLocalCopy(g);
else if (state.filter === 'not_backed') statusOk = window.chimeraLibraryModel.needsProtection(g);
if (!statusOk) return false;
if (state.firmwareFilter !== 'all') {
const labels = (g.firmware_labels || '').split(',').filter(Boolean);
if (!labels.includes(state.firmwareFilter)) return false;
}
return true;
}).sort((a, b) => {
const sortValue = game => state.sortCol === 'title'
? (game.prospero_name || game.title || game.game_id || '')
: (game[state.sortCol] ?? '');
let av = sortValue(a), bv = sortValue(b);
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av === bv) return 0;
return state.sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
});
}
function renderGames() {
renderGamePlatformFilters();
const allGames = getFilteredGames();
const list = allGames.slice(0, state.gameRenderLimit);
const tbody = $('games-tbody');
const grid = $('games-grid');
const tableWrap = $('games-table-wrap');
const empty = $('games-empty');
const emptyDetail = $('games-empty-detail');
const emptyScan = $('btn-games-empty-scan');
const emptyReset = $('btn-games-empty-reset');
const loadMore = $('btn-games-load-more');
const resultCount = $('games-result-count');
const filtersActive = Boolean(
state.games.length && (
state.search ||
state.filter !== 'all' ||
state.platformFilter !== 'all' ||
state.firmwareFilter !== 'all'
)
);
tbody.innerHTML = '';
grid.innerHTML = '';
empty.hidden = allGames.length !== 0;
emptyDetail.textContent = filtersActive
? window.t('games.empty.filtered', 'No games match the selected platform and filters.')
: window.t('games.empty.detail', 'Connect the PS5 or scan the configured source folders.');
emptyScan.hidden = filtersActive;
emptyReset.hidden = !filtersActive;
grid.hidden = !allGames.length || state.gameView !== 'grid';
tableWrap.hidden = !allGames.length || state.gameView !== 'table';
loadMore.hidden = list.length >= allGames.length;
resultCount.textContent = allGames.length
? window.tf('games.resultCount', '{visible} of {total}', { visible: list.length, total: allGames.length })
: '';
document.querySelectorAll('[data-game-view]').forEach(button => {
const active = button.dataset.gameView === state.gameView;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
for (const g of list) {
const tr = document.createElement('tr');
tr.className = 'game-row';
tr.dataset.id = g.game_id;
// Compact status cell: readable tags instead of cryptic symbols
const patchesOnly = g.backup_count === 0 && g.backpork_count > 0;
const _hs = _gameHashSummary.get(g.game_id);
const hashTag = _hs
? _hs.community_mismatch
? `<span class="s-tag s-hash-fail" title="⚠ Community hash check FAILED — files may be modified or contain brick code">⚠ Hash Fail</span>`
: _hs.community_matches > 0
? `<span class="s-tag s-hash-ok" title="Community verified: ${_hs.community_matches}/${_hs.hash_count} files match known-good hashes">✓ Verified</span>`
: `<span class="s-tag s-hash-local" title="${_hs.hash_count} file(s) hashed — not yet in community list">⧭ Hashed</span>`
: '';
const fwLabels = (g.firmware_labels || '').split(',').filter(Boolean);
const patchedTag = fwLabels.length
? `<span class="s-tag s-patched" title="Backporked for: ${escHtml(fwLabels.join(', '))}">&#x1F527; ${escHtml(fwLabels.join(', '))}</span>`
: '';
const shadowTag = g.shadow_mount
? `<span class="s-tag ${g.shadow_mount.state === 'installed' ? 's-backed' : 's-warn'}" title="ShadowMount: ${escHtml(g.shadow_mount.sources)} source(s) · ${escHtml((g.shadow_mount.formats || []).join(', ') || 'unknown format')}${g.shadow_mount.experimental ? ' · experimental format' : ''}">&#9673; Mount ${escHtml(g.shadow_mount.state)}</span>`
: '';
const metadataTag = `<span class="s-tag ${g.metadata?.source === 'manual' ? 's-patched' : 's-num'}" title="Metadata: ${escHtml(g.metadata?.source || 'unknown')} · ${escHtml(window.t('games.metadataConfidence', 'confidence'))} ${escHtml(g.metadata?.confidence || 'unknown')}">Meta ${escHtml(g.metadata?.source || '?')}</span>`;
const variantTag = g.regional_variants?.length
? `<span class="s-tag s-num" title="${escHtml(g.regional_variants.map(item => `${item.titleId}${item.region ? ` (${item.region})` : ''}`).join(' · '))}">${g.regional_variants.length} ${escHtml(g.regional_variants.length === 1 ? window.t('games.region', 'region') : window.t('games.regions', 'regions'))}</span>`
: '';
const platform = gamePlatform(g);
const platformLabel = platform === 'unknown'
? window.t('games.platform.unknown', 'Unknown')
: platform.toUpperCase();
const platformTag = `<span class="s-tag s-platform-${platform}">${escHtml(platformLabel)}</span>`;
const requiredDrives = disconnectedDriveLabels(g);
const requiredDriveTag = requiredDrives.length
? `<span class="s-tag s-warn s-required-drive" title="${escHtml(window.t('games.requiredDrive.title', 'Reconnect the named drive to access this copy'))}">${escHtml(window.t('games.requiredDrive', 'Needs drive'))}: ${escHtml(requiredDrives.join(', '))}</span>`
: '';
const statusHtml = `<div class="game-status-col">
${platformTag}
${requiredDriveTag}
${window.chimeraLibraryModel.isInstalled(g) ? `<span class="s-tag s-installed" title="${escHtml(window.t('games.status.installedTitle', 'Currently installed on the PS5'))}">${escHtml(window.t('games.status.installed', 'Installed'))}</span>` : ''}
${g.backup_count > 0 ? `<span class="s-tag s-num" title="${g.backup_count} ${escHtml(window.t('games.status.localBackups', 'local backup(s)'))}">${g.backup_count} bkp</span>` : ''}
${window.chimeraLibraryModel.hasLocalCopy(g) ? `<span class="s-tag s-backed" title="${escHtml(window.t('games.status.backedUpTitle', 'Local copy available'))}">&#10003;</span>` : ''}
${patchesOnly ? `<span class="s-tag s-warn" title="${escHtml(window.t('games.status.patchesOnlyTitle', 'Only Backpork patches were found; no local game backup exists'))}">${escHtml(window.t('games.status.patchesOnly', 'Patches only'))}</span>` : ''}
${patchedTag}
${shadowTag}
${hashTag}
${metadataTag}
${variantTag}
</div>`;
const displayTitle = g.prospero_name || g.title || g.game_id;
const metadataLine = [
g.prospero_publisher,
g.prospero_region,
g.content_id || g.prospero_content_id,
].filter(Boolean).join(' · ');
const coverUrl = g.prospero_cover_url || '';
const renderedCoverUrl = versionedArtworkUrl(coverUrl, g.cover_fetched_at);
const iconFallbackAttribute = g.prospero_icon_url
? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"`
: '';
const coverPlaceholder = `<div class="game-card-cover-fallback" hidden aria-hidden="true"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>`;
const cover = renderedCoverUrl
? `<div class="game-card-cover-case has-native-cover">
<img src="${escHtml(renderedCoverUrl)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback"${iconFallbackAttribute}>
${coverPlaceholder}
</div>`
: g.prospero_icon_url
? `<img src="${escHtml(g.prospero_banner_url || g.prospero_icon_url)}" class="game-card-cover-backdrop" alt="" aria-hidden="true" loading="lazy" decoding="async">
<div class="game-card-cover-case">
<span class="game-card-cover-platform">${escHtml(platformLabel)}</span>
<img src="${escHtml(g.prospero_icon_url)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback">
${coverPlaceholder}
<div class="game-card-cover-caption"><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>
</div>`
: `<div class="game-card-cover-fallback is-visible" aria-label="${escHtml(window.t?.('games.coverMissing', 'Cover unavailable') || 'Cover unavailable')}"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id || '?')}</small></div>`;
const metadataSynced = Boolean(g.prospero_name && g.prospero_icon_url && g.prospero_cover_url);
const metadataState = metadataSynced
? window.t('games.metadata.synced', 'Metadata: synced')
: window.t('games.metadata.enriching', 'Metadata: enriching');
const card = document.createElement('article');
card.className = 'game-card';
card.dataset.id = g.game_id;
card.dataset.platform = platform;
card.style.setProperty('--card-hue', String(titleHue(g.game_id)));
card.tabIndex = 0;
card.innerHTML = `
<div class="game-card-art">${cover}
${metadataSynced ? '' : `<span class="game-card-sync pending" title="${escHtml(metadataState)}">${escHtml(metadataState)}</span>`}
<div class="game-card-actions">
${patchesOnly ? '' : `<button class="btn btn-sm btn-accent" data-action="install" data-id="${escHtml(g.game_id)}" data-i18n="games.install">Plan install</button>`}
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" data-i18n="games.metadata">Metadata</button>
</div>
</div>
<div class="game-card-body">
<div class="game-card-heading">
<strong title="${escHtml(displayTitle)}">${escHtml(displayTitle)}</strong>
</div>
<span class="game-card-meta">${escHtml([g.game_id, metadataLine].filter(Boolean).join(' · '))}</span>
${statusHtml}
</div>`;
const nativeCover = card.querySelector('.game-card-cover-case.has-native-cover .game-card-cover');
if (nativeCover) {
const applyNaturalCoverRatio = () => {
const width = Number(nativeCover.naturalWidth || 0);
const height = Number(nativeCover.naturalHeight || 0);
const ratio = height > 0 ? width / height : 0;
if (width >= 160 && height >= 240 && ratio >= 0.52 && ratio <= 0.86) {
card.style.setProperty('--game-cover-ratio', `${width} / ${height}`);
}
};
if (nativeCover.complete) applyNaturalCoverRatio();
else nativeCover.addEventListener('load', applyNaturalCoverRatio, { once: true });
}
grid.appendChild(card);
if (patchesOnly) tr.classList.add('game-row-patches-only');
tr.innerHTML = `
<td>
<div class="game-title-cell">
${renderedCoverUrl || g.prospero_icon_url
? `<img src="${escHtml(renderedCoverUrl || g.prospero_icon_url)}" class="game-row-icon${renderedCoverUrl ? ' is-portrait' : ''}" alt="${escHtml(window.t('games.coverFor', 'Cover for'))} ${escHtml(g.prospero_name || g.title || g.game_id)}" loading="lazy" decoding="async" data-image-error="fallback"${renderedCoverUrl && g.prospero_icon_url ? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"` : ''}>`
: '<div class="game-row-icon game-row-icon-empty"></div>'}
${renderedCoverUrl || g.prospero_icon_url ? '<div class="game-row-icon game-row-icon-empty" hidden></div>' : ''}
<span class="game-title-copy">
<strong>${escHtml(g.prospero_name || g.title || g.game_id)}</strong>
<small>${escHtml([
g.prospero_publisher,
g.prospero_region,
g.content_id || g.prospero_content_id,
].filter(Boolean).join(' · ') || window.t('games.metadataQueued', 'Metadata is being enriched…'))}</small>
</span>
</div>
</td>
<td><code>${escHtml(g.game_id)}</code></td>
<td>${escHtml(g.version || g.prospero_version || '—')}</td>
<td>${g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))}</td>
<td>${renderFwPills(g.firmware_labels)}</td>
<td>${statusHtml}</td>
<td class="game-actions-cell">
${patchesOnly
? `<span class="patches-only-hint" title="${escHtml(window.t('games.noBackupDetail', 'No game backup was found; only patch folders exist. Create or download a base-game backup first.'))}">&#9888; ${escHtml(window.t('games.noBackup', 'No game backup'))}</span>`
: `<button class="btn btn-sm btn-teal" data-action="install" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.installQueueTitle', 'Add to the PS5 installation queue'))}">
&#11014; ${escHtml(window.t('games.install', 'Plan install'))}
</button>
<button class="btn btn-sm" data-action="convert" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.convertTitle', 'Convert the game and optionally upload it to the PS5'))}">
&#9881; ${escHtml(window.t('games.convert', 'Convert'))}
</button>`
}
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.metadataEditTitle', 'Correct the title, artwork and description manually'))}">Metadata</button>
</td>`;
tbody.appendChild(tr);
}
// Click delegation: action buttons take priority; otherwise the card/row opens detail.
const handleLibraryClick = async e => {
const btn = e.target.closest('[data-action]');
if (btn) {
try {
if (btn.dataset.action === 'install') {
await openInstallModal(btn.dataset.id, 'pfs');
} else if (btn.dataset.action === 'convert') {
const s = await window.pork.getSettings();
const mode = (s.gameConversionMode === 'pfs' || !s.gameConversionMode)
? (window.pork.isWebMode ? 'ffpfsc' : 'ffpkg')
: s.gameConversionMode;
await openInstallModal(btn.dataset.id, mode);
} else if (btn.dataset.action === 'metadata') {
await editGameMetadata(btn.dataset.id);
}
} catch (error) {
setStatus(error.message, 'error');
showToast(error.message, 'error', 9000);
}
return;
}
const item = e.target.closest('[data-id].game-row, [data-id].game-card');
if (item) openModal(item.dataset.id);
};
tbody.onclick = handleLibraryClick;
grid.onclick = handleLibraryClick;
grid.onkeydown = e => {
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) {
e.preventDefault();
const card = e.target.closest('.game-card[data-id]');
if (card) openModal(card.dataset.id);
}
};
window.i18n?.apply(grid);
window.i18n?.apply(tbody);
}
async function editGameMetadata(gameId) {
const game = state.games.find(item => item.game_id === gameId);
if (!game) return;
const hasOverride = Boolean(game.metadata?.overriddenFields?.length);
if (hasOverride && await showExactConfirmation('Clear manual metadata and return to online and local metadata management?', 'CLEAR METADATA')) {
await window.pork.updateGameMetadata(gameId, {});
await loadGames();
setStatus(`Handmatige metadata voor ${gameId} gewist.`, 'ok');
return;
}
const ask = (label, value = '') => showPrompt(`${label} voor ${gameId}:`, String(value || ''));
const title = await ask('Volledige titel', game.prospero_name || game.title);
if (title == null) return;
const publisher = await ask('Uitgever', game.prospero_publisher);
if (publisher == null) return;
const region = await ask('Regio', game.prospero_region);
if (region == null) return;
const iconUrl = await ask(window.t?.('games.metadata.iconUrl', 'HTTPS image URL') || 'HTTPS image URL', game.prospero_icon_url);
if (iconUrl == null) return;
if (iconUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(iconUrl)) {
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
}
const coverUrl = await ask(window.t?.('games.metadata.coverUrl', 'HTTPS portrait cover URL') || 'HTTPS portrait cover URL', game.prospero_cover_url);
if (coverUrl == null) return;
if (coverUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(coverUrl)) {
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
}
const description = await ask(window.t?.('games.metadata.description', 'Description') || 'Description', game.prospero_description);
if (description == null) return;
await window.pork.updateGameMetadata(gameId, { title, publisher, region, iconUrl, coverUrl, description });
await loadGames();
setStatus(`Metadata voor ${gameId} handmatig vastgezet.`, 'success');
}
// Sorting
document.querySelectorAll('#games-table th[data-col]').forEach(th => {
th.addEventListener('click', () => {
if (state.sortCol === th.dataset.col) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
else { state.sortCol = th.dataset.col; state.sortDir = 'asc'; }
localStorage.setItem('chimera.games.sortCol', state.sortCol);
localStorage.setItem('chimera.games.sortDir', state.sortDir);
renderGames();
});
});
// Filter buttons
document.querySelectorAll('.fbtn').forEach(btn =>
btn.addEventListener('click', () => {
document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.filter = btn.dataset.filter;
state.gameRenderLimit = 160;
localStorage.setItem('chimera.games.filter', state.filter);
renderGames();
})
);
document.querySelectorAll('[data-game-platform]').forEach(button => {
button.addEventListener('click', () => {
state.platformFilter = ['ps4', 'ps5', 'unknown'].includes(button.dataset.gamePlatform)
? button.dataset.gamePlatform
: 'all';
state.gameRenderLimit = 160;
localStorage.setItem('chimera.games.platform', state.platformFilter);
renderGames();
});
});
// Search
$('games-search').addEventListener('input', e => {
state.search = e.target.value;
state.gameRenderLimit = 160;
renderGames();
});
document.querySelectorAll('[data-game-view]').forEach(button => {
button.addEventListener('click', () => {
state.gameView = button.dataset.gameView === 'table' ? 'table' : 'grid';
localStorage.setItem('chimera.games.view', state.gameView);
renderGames();
});
});
$('btn-games-load-more')?.addEventListener('click', () => {
state.gameRenderLimit += 160;
renderGames();
});
$('btn-games-empty-scan')?.addEventListener('click', () => $('btn-rescan-game-sources')?.click());
$('btn-games-empty-reset')?.addEventListener('click', () => {
state.search = '';
state.filter = 'all';
state.platformFilter = 'all';
state.firmwareFilter = 'all';
state.gameRenderLimit = 160;
$('games-search').value = '';
localStorage.setItem('chimera.games.filter', 'all');
localStorage.setItem('chimera.games.platform', 'all');
document.querySelectorAll('.fwbtn').forEach(button => {
button.classList.toggle('active', button.dataset.fw === 'all');
});
renderGames();
});
document.querySelectorAll('.fbtn').forEach(button => {
button.classList.toggle('active', button.dataset.filter === state.filter);
});
window.addEventListener('chimera:language-changed', () => renderGames());