1072 lines
59 KiB
JavaScript
1072 lines
59 KiB
JavaScript
'use strict';
|
||
|
||
(function initChimeraNavigation() {
|
||
const contract = window.ChimeraNavigationContract;
|
||
if (!contract) {
|
||
console.error('[navigation] ChimeraNavigationContract is missing.');
|
||
return;
|
||
}
|
||
|
||
const routeById = new Map(contract.routes.map(route => [route.id, route]));
|
||
const groupById = new Map(contract.groups.map(group => [group.id, group]));
|
||
const expandedGroups = new Set();
|
||
const iconPaths = {
|
||
home: '<path d="M3 9.5 10 3l7 6.5V17a1 1 0 0 1-1 1h-4v-5H8v5H4a1 1 0 0 1-1-1z"/>',
|
||
library: '<path d="M3 4.5h5.5A2.5 2.5 0 0 1 11 7v10H5a2 2 0 0 0-2 1.2zM17 4.5h-3.5A2.5 2.5 0 0 0 11 7v10h4a2 2 0 0 1 2 1.2z"/>',
|
||
console: '<rect x="2.5" y="5" width="15" height="10" rx="4"/><path d="M6 10h4M8 8v4M13.5 9.5h.01M15.5 11.5h.01"/>',
|
||
payload: '<path d="M11 2 5 11h4l-1 7 7-10h-4z"/>',
|
||
shield: '<path d="M10 2.5 16 5v4.5c0 4.1-2.4 6.8-6 8.5-3.6-1.7-6-4.4-6-8.5V5z"/><path d="m7 10 2 2 4-4"/>',
|
||
tools: '<path d="m12.5 3.5 4 4-9 9H3.5v-4zM10.5 5.5l4 4"/><path d="M13 15h4M15 13v4"/>',
|
||
settings: '<circle cx="10" cy="10" r="3"/><path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4"/>',
|
||
action: '<path d="M4 5.5h12M4 10h8M4 14.5h6"/><circle cx="15.5" cy="14.5" r="2"/>',
|
||
more: '<circle cx="4" cy="10" r="1"/><circle cx="10" cy="10" r="1"/><circle cx="16" cy="10" r="1"/>',
|
||
};
|
||
|
||
let currentRouteId = null;
|
||
let activePayloadStage = 'discover';
|
||
let navigationSequence = 0;
|
||
let commandGameCache = null;
|
||
let commandGameCacheAt = 0;
|
||
let commandSearchSequence = 0;
|
||
let currentTransferState = { jobs: [], activeCount: 0, queuedCount: 0, paused: false };
|
||
let currentActionCount = 0;
|
||
let expertMode = false;
|
||
|
||
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
|
||
const routeKey = route => `navigation.routes.${route.id.replace(/\//g, '.')}`;
|
||
const routeLabel = route => tr(`${routeKey(route)}.label`, route.label);
|
||
const routeDescription = route => tr(`${routeKey(route)}.description`, route.description);
|
||
const groupLabel = group => tr(`navigation.groups.${group.id}`, group.label);
|
||
const activeLocale = () => window.i18n?.current === 'nl' ? 'nl-BE' : 'en-GB';
|
||
|
||
const escapeText = value => String(value ?? '')
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
|
||
function icon(name) {
|
||
return `<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">${iconPaths[name] || iconPaths.home}</svg>`;
|
||
}
|
||
|
||
function normalizeRoute(value) {
|
||
let routeId = String(value || '').trim().replace(/^#\/?/, '').replace(/^\/+|\/+$/g, '');
|
||
try { routeId = decodeURIComponent(routeId); } catch (_) {}
|
||
routeId = contract.legacyAliases[routeId] || routeId;
|
||
if (!routeById.has(routeId)) return contract.defaultRoute;
|
||
const route = routeById.get(routeId);
|
||
if (route.expert && !expertMode) return groupById.get(route.group)?.defaultRoute || contract.defaultRoute;
|
||
return routeId;
|
||
}
|
||
|
||
async function syncExpertPermission() {
|
||
if (typeof window.pork?.sessionExpertMode !== 'function') return expertMode;
|
||
try {
|
||
const result = await window.pork.sessionExpertMode(expertMode, expertMode ? 'ENABLE EXPERT MODE' : undefined);
|
||
expertMode = result?.enabled === true;
|
||
} catch (_) {
|
||
expertMode = false;
|
||
}
|
||
document.body.classList.toggle('chimera-expert-mode', expertMode);
|
||
const toggle = document.getElementById('chimera-expert-mode');
|
||
if (toggle) toggle.checked = expertMode;
|
||
return expertMode;
|
||
}
|
||
|
||
setTimeout(syncExpertPermission);
|
||
|
||
function routeFromLocation() {
|
||
return normalizeRoute(window.location.hash);
|
||
}
|
||
|
||
function routeUrl(routeId) {
|
||
return `#/${encodeURI(routeId)}`;
|
||
}
|
||
|
||
function mountSidebar() {
|
||
const navList = document.querySelector('#sidebar .nav-list');
|
||
if (!navList) return;
|
||
navList.className = 'nav-list chimera-nav-root';
|
||
navList.setAttribute('aria-label', 'Chimera main navigation');
|
||
navList.innerHTML = contract.groups.map(group => {
|
||
const isHome = group.routes.length === 1 && group.routes[0].id === 'home';
|
||
const routeLinks = group.routes.map(route => `
|
||
<li>
|
||
<a href="${routeUrl(route.id)}" class="chimera-route-link${route.expert ? ' chimera-expert-route' : ''}" data-route="${escapeText(route.id)}" title="${escapeText(routeDescription(route))}">
|
||
<span data-i18n="${escapeText(`${routeKey(route)}.label`)}">${escapeText(routeLabel(route))}</span>
|
||
${route.risk === 'confirm' ? `<span class="chimera-risk-dot" title="${escapeText(tr('navigation.confirmationRequired', 'Action requires explicit confirmation'))}">!</span>` : ''}
|
||
${route.badge === 'transfer' ? '<span id="nav-transfer-badge" class="nav-badge" hidden></span>' : ''}
|
||
</a>
|
||
</li>`).join('');
|
||
return `
|
||
<li class="chimera-domain" data-nav-group="${escapeText(group.id)}">
|
||
${isHome ? `
|
||
<a href="${routeUrl(group.defaultRoute)}" class="chimera-domain-button chimera-domain-home" data-route="${escapeText(group.defaultRoute)}">
|
||
${icon(group.icon)}<span data-i18n="${escapeText(`navigation.groups.${group.id}`)}">${escapeText(groupLabel(group))}</span>
|
||
</a>` : `
|
||
<button class="chimera-domain-button" type="button" data-group-toggle="${escapeText(group.id)}" aria-expanded="false">
|
||
${icon(group.icon)}<span data-i18n="${escapeText(`navigation.groups.${group.id}`)}">${escapeText(groupLabel(group))}</span><span class="chimera-domain-chevron">›</span>
|
||
</button>
|
||
<ul class="chimera-domain-routes" data-group-routes="${escapeText(group.id)}" hidden>${routeLinks}</ul>`}
|
||
</li>`;
|
||
}).join('') + `<li class="chimera-expert-toggle"><label><input id="chimera-expert-mode" type="checkbox" ${expertMode ? 'checked' : ''}><span><strong data-i18n="navigation.expert.label">Expert tools</strong><small data-i18n="navigation.expert.description">Database, logs and low-level tools</small></span></label></li>`;
|
||
|
||
document.body.classList.toggle('chimera-expert-mode', expertMode);
|
||
|
||
const aboutLabel = document.querySelector('#sidebar-donate .donate-label');
|
||
if (aboutLabel) {
|
||
aboutLabel.removeAttribute('data-i18n');
|
||
aboutLabel.textContent = tr('navigation.about', 'About Chimera');
|
||
aboutLabel.dataset.i18n = 'navigation.about';
|
||
}
|
||
}
|
||
|
||
function mountShell() {
|
||
document.body.classList.add('chimera-shell-v2');
|
||
const content = document.getElementById('content');
|
||
if (!content) return;
|
||
|
||
const toolbar = document.createElement('div');
|
||
toolbar.id = 'chimera-shell-toolbar';
|
||
toolbar.innerHTML = `
|
||
<div id="chimera-shell-health" class="chimera-shell-health is-offline" role="status" aria-live="polite" aria-label="${escapeText(tr('connection.consoleState', 'PS5 connection status'))}" data-i18n-aria-label="connection.consoleState">
|
||
<span class="chimera-shell-health-dot" aria-hidden="true"></span>
|
||
<span class="chimera-shell-health-copy">
|
||
<small data-i18n="connection.console">Console</small>
|
||
<strong id="chimera-shell-health-label" data-i18n="connection.disconnected">Disconnected</strong>
|
||
</span>
|
||
</div>
|
||
<span class="chimera-toolbar-spacer" aria-hidden="true"></span>
|
||
<button id="chimera-command-open" class="chimera-toolbar-button" type="button" aria-haspopup="dialog">
|
||
<span data-i18n="navigation.search.open">Search or navigate</span><kbd>Ctrl K</kbd>
|
||
</button>
|
||
<button id="chimera-jobs-open" class="chimera-toolbar-button chimera-jobs-button" type="button" aria-haspopup="dialog" aria-label="${escapeText(tr('navigation.actionCenter.open', 'Action Center'))}" data-i18n-aria-label="navigation.actionCenter.open">
|
||
<span class="chimera-jobs-pulse"></span><span data-i18n="navigation.actionCenter.open">Action Center</span><span id="chimera-job-count" class="nav-badge" hidden></span>
|
||
</button>`;
|
||
const mobileToggle = document.getElementById('mobile-nav-toggle');
|
||
if (mobileToggle) toolbar.prepend(mobileToggle);
|
||
|
||
const header = document.createElement('header');
|
||
header.id = 'chimera-route-header';
|
||
header.innerHTML = `
|
||
<div class="chimera-route-copy">
|
||
<nav id="chimera-breadcrumbs" aria-label="Breadcrumb"></nav>
|
||
<h1 id="chimera-route-title">Chimera</h1>
|
||
<p id="chimera-route-description"></p>
|
||
</div>
|
||
<div class="chimera-route-actions">
|
||
<span id="chimera-route-availability" class="chimera-route-availability" role="status" hidden></span>
|
||
<span id="chimera-route-risk" class="chimera-route-risk" hidden data-i18n="navigation.confirmationRequired">Explicit confirmation required</span>
|
||
<button id="chimera-route-refresh" class="btn btn-sm" type="button">↻ <span data-i18n="common.refresh">Refresh</span></button>
|
||
</div>`;
|
||
content.prepend(header);
|
||
content.prepend(toolbar);
|
||
|
||
const palette = document.createElement('div');
|
||
palette.id = 'chimera-command-overlay';
|
||
palette.className = 'chimera-overlay';
|
||
palette.hidden = true;
|
||
palette.innerHTML = `
|
||
<section id="chimera-command-dialog" class="chimera-command-dialog" role="dialog" aria-modal="true" aria-labelledby="chimera-command-title">
|
||
<div class="chimera-command-head">
|
||
<div><span class="chimera-command-kicker">Chimera</span><h2 id="chimera-command-title" data-i18n="navigation.search.title">Go to…</h2></div>
|
||
<button class="chimera-icon-button" data-command-close type="button" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="chimera-command-search" role="search">
|
||
<span class="chimera-command-search-icon" aria-hidden="true">⌕</span>
|
||
<input id="chimera-command-input" type="search" autocomplete="off" spellcheck="false" aria-label="Search Chimera" aria-controls="chimera-command-results" aria-describedby="chimera-command-help" placeholder="Search feature, tool, game or title ID…" data-i18n-placeholder="navigation.search.placeholder"/>
|
||
<button id="chimera-command-clear" class="chimera-command-clear" type="button" aria-label="Clear search" title="Clear search" hidden>×</button>
|
||
</div>
|
||
<div class="chimera-command-meta">
|
||
<span id="chimera-command-help" data-i18n="navigation.search.help">Type to filter features; game search starts after 2 characters.</span>
|
||
<span id="chimera-command-status" role="status" aria-live="polite"></span>
|
||
</div>
|
||
<div id="chimera-command-results" class="chimera-command-results" role="listbox" aria-label="Search results"></div>
|
||
<footer><span><kbd>↑</kbd><kbd>↓</kbd> <span data-i18n="navigation.search.select">select</span></span><span><kbd>Enter</kbd> <span data-i18n="navigation.search.openResult">open</span></span><span><kbd>Esc</kbd> <span data-i18n="navigation.search.close">close</span></span></footer>
|
||
</section>`;
|
||
document.body.appendChild(palette);
|
||
|
||
const drawer = document.createElement('div');
|
||
drawer.id = 'chimera-jobs-overlay';
|
||
drawer.className = 'chimera-overlay chimera-drawer-overlay';
|
||
drawer.hidden = true;
|
||
drawer.innerHTML = `
|
||
<aside id="chimera-jobs-drawer" class="chimera-jobs-drawer" role="dialog" aria-modal="true" aria-labelledby="chimera-jobs-title">
|
||
<div class="chimera-drawer-head"><div><span class="chimera-command-kicker" data-i18n="navigation.actionCenter.kicker">System attention</span><h2 id="chimera-jobs-title" data-i18n="navigation.actionCenter.title">Action Center</h2></div><button class="chimera-icon-button" data-jobs-close type="button" aria-label="Close">×</button></div>
|
||
<section class="chimera-action-section" aria-labelledby="chimera-action-title">
|
||
<div class="chimera-drawer-section-title"><h3 id="chimera-action-title" data-i18n="navigation.actionCenter.attention">Needs attention</h3><button class="btn btn-sm" data-route="console/status" type="button" data-i18n="navigation.actionCenter.health">Console health</button></div>
|
||
<div id="chimera-action-list" class="chimera-action-list"><p class="hint" data-i18n="common.loading">Loading…</p></div>
|
||
</section>
|
||
<div class="chimera-drawer-section-title"><h3 data-i18n="navigation.actionCenter.transfers">Transfers</h3></div>
|
||
<div id="chimera-jobs-summary" class="chimera-jobs-summary"></div>
|
||
<div id="chimera-jobs-list" class="chimera-jobs-list"></div>
|
||
<button class="btn btn-accent" data-route="tools/transfers" type="button" data-i18n="navigation.jobs.open">Open full Transfer Manager</button>
|
||
</aside>`;
|
||
document.body.appendChild(drawer);
|
||
|
||
}
|
||
|
||
function mountCompanionDock() {
|
||
if (document.getElementById('chimera-companion-dock')) return;
|
||
const dock = document.createElement('nav');
|
||
dock.id = 'chimera-companion-dock';
|
||
dock.className = 'chimera-companion-dock';
|
||
dock.setAttribute('aria-label', tr('navigation.companion.label', 'Companion shortcuts'));
|
||
dock.dataset.i18nAriaLabel = 'navigation.companion.label';
|
||
dock.innerHTML = `
|
||
<a href="${routeUrl('home')}" data-route="home" data-companion-group="home">
|
||
${icon('home')}<span data-i18n="navigation.groups.home">Home</span>
|
||
</a>
|
||
<a href="${routeUrl('library/games')}" data-route="library/games" data-companion-group="library">
|
||
${icon('library')}<span data-i18n="nav.games">Games</span>
|
||
</a>
|
||
<a href="${routeUrl('console/status')}" data-route="console/status" data-companion-group="console">
|
||
${icon('console')}<span data-i18n="navigation.groups.console">Console</span>
|
||
</a>
|
||
<button id="chimera-companion-actions" type="button" aria-haspopup="dialog">
|
||
${icon('action')}<span data-i18n="navigation.companion.actions">Actions</span>
|
||
<b id="chimera-companion-count" hidden></b>
|
||
</button>
|
||
<button id="chimera-companion-more" type="button" aria-controls="sidebar" aria-expanded="false">
|
||
${icon('more')}<span data-i18n="navigation.companion.more">More</span>
|
||
</button>`;
|
||
document.body.appendChild(dock);
|
||
}
|
||
|
||
function mountHomeCommandCenter() {
|
||
const page = document.getElementById('page-dashboard');
|
||
const template = document.getElementById('chimera-home-command-center-template');
|
||
if (!page || !template || document.getElementById('chimera-home-command-center')) return;
|
||
const launchpad = document.createElement('section');
|
||
launchpad.id = 'chimera-home-command-center';
|
||
launchpad.setAttribute('aria-label', 'Chimera status and next steps');
|
||
launchpad.append(template.content.cloneNode(true));
|
||
const pageHeader = page.querySelector(':scope > .page-header');
|
||
if (pageHeader) pageHeader.insertAdjacentElement('afterend', launchpad);
|
||
else page.prepend(launchpad);
|
||
}
|
||
|
||
function mountSettingsNavigator() {
|
||
const page = document.getElementById('page-settings');
|
||
if (!page || document.getElementById('chimera-settings-navigator')) return;
|
||
const cards = [...page.querySelectorAll(':scope > .card')];
|
||
if (!cards.length) return;
|
||
|
||
const categoryFor = (card, label) => {
|
||
const id = String(card.id || '');
|
||
const value = `${id} ${label}`.toLowerCase();
|
||
if (/appearance|language/.test(value)) return 'setup';
|
||
if (/console profile|console identity|ftp connection|payload manager|cheats manager|save manager/.test(value)) return 'console';
|
||
if (/game source|conversion|remote game|metadata|ps4 package|package preservation/.test(value)) return 'storage';
|
||
if (/automation advisor/.test(value)) return 'automation';
|
||
if (/two-factor|passkey|security/.test(value)) return 'security';
|
||
if (/payload remote|integration|notification|companion/.test(value)) return 'integrations';
|
||
return 'advanced';
|
||
};
|
||
const sections = cards.map((card, index) => {
|
||
const heading = card.querySelector('h2, h3, h4');
|
||
const label = heading?.textContent?.trim() || `${tr('settingsNavigator.section', 'Section')} ${index + 1}`;
|
||
if (!card.id) card.id = `settings-section-${index + 1}`;
|
||
card.style.scrollMarginTop = '108px';
|
||
const category = categoryFor(card, label);
|
||
card.dataset.settingsCategory = category;
|
||
return { card, id: card.id, label, category };
|
||
});
|
||
const categories = [
|
||
['setup', tr('settingsNavigator.setup', 'Setup')],
|
||
['console', tr('settingsNavigator.console', 'Console')],
|
||
['storage', tr('settingsNavigator.storage', 'Storage')],
|
||
['automation', tr('settingsNavigator.automation', 'Automation')],
|
||
['integrations', tr('settingsNavigator.integrations', 'Integrations')],
|
||
['security', tr('settingsNavigator.security', 'Security')],
|
||
['advanced', tr('settingsNavigator.advanced', 'Advanced')],
|
||
];
|
||
let activeCategory = 'setup';
|
||
|
||
const navigator = document.createElement('section');
|
||
navigator.id = 'chimera-settings-navigator';
|
||
navigator.className = 'chimera-settings-navigator';
|
||
navigator.setAttribute('aria-label', 'Search and navigate settings');
|
||
navigator.innerHTML = `
|
||
<div class="chimera-settings-navigator-copy">
|
||
<strong data-i18n="settingsNavigator.title">Find a setting quickly</strong>
|
||
<span id="chimera-settings-filter-status" role="status" aria-live="polite">${sections.length} ${tr('settingsNavigator.sections', 'sections')}</span>
|
||
</div>
|
||
<div class="chimera-settings-controls">
|
||
<div class="chimera-settings-search" role="search">
|
||
<span aria-hidden="true">⌕</span>
|
||
<input id="chimera-settings-search" type="search" autocomplete="off" spellcheck="false" aria-label="Search settings" placeholder="Search FTP, paths, notifications…" data-i18n-placeholder="settingsNavigator.placeholder"/>
|
||
<button id="chimera-settings-clear" type="button" aria-label="Clear settings filter" title="Clear filter" hidden>×</button>
|
||
</div>
|
||
<label class="chimera-settings-jump">
|
||
<span data-i18n="settingsNavigator.jump">Jump to</span>
|
||
<select id="chimera-settings-jump" aria-label="Jump to settings section" data-i18n-aria-label="settingsNavigator.jumpAria">
|
||
<option value="" data-i18n="settingsNavigator.choose">Choose a section…</option>
|
||
${sections.map(section => `<option value="${escapeText(section.id)}">${escapeText(section.label)}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="chimera-settings-categories" role="tablist" aria-label="Settings categories">
|
||
${categories.map(([id, label], index) => `<button type="button" role="tab" data-settings-category="${id}" aria-selected="${index === 0 ? 'true' : 'false'}">${label}<span>${sections.filter(section => section.category === id).length}</span></button>`).join('')}
|
||
</div>`;
|
||
|
||
const pageHeader = page.querySelector(':scope > .page-header');
|
||
if (pageHeader) pageHeader.insertAdjacentElement('afterend', navigator);
|
||
else page.prepend(navigator);
|
||
|
||
const input = navigator.querySelector('#chimera-settings-search');
|
||
const clearButton = navigator.querySelector('#chimera-settings-clear');
|
||
const status = navigator.querySelector('#chimera-settings-filter-status');
|
||
const jump = navigator.querySelector('#chimera-settings-jump');
|
||
const syncSections = () => {
|
||
sections.forEach((section, index) => {
|
||
const liveLabel = section.card.querySelector('h2, h3, h4')?.textContent?.trim() || section.label;
|
||
section.label = liveLabel;
|
||
if (jump.options[index + 1]) jump.options[index + 1].textContent = liveLabel;
|
||
});
|
||
};
|
||
const applyFilter = value => {
|
||
const query = String(value || '').trim().toLocaleLowerCase(activeLocale());
|
||
let visible = 0;
|
||
sections.forEach(section => {
|
||
const searchText = section.card.textContent.toLocaleLowerCase(activeLocale());
|
||
const matchesCategory = activeCategory === 'all' || section.category === activeCategory;
|
||
const matches = matchesCategory && (!query || searchText.includes(query));
|
||
section.card.toggleAttribute('data-settings-filter-hidden', !matches);
|
||
if (matches) visible += 1;
|
||
});
|
||
clearButton.hidden = !query;
|
||
status.textContent = query || activeCategory !== 'all'
|
||
? `${visible} ${tr('settingsNavigator.of', 'of')} ${sections.length} ${tr('settingsNavigator.sections', 'sections')}`
|
||
: `${sections.length} ${tr('settingsNavigator.sections', 'sections')}`;
|
||
};
|
||
applyFilter('');
|
||
|
||
navigator.querySelector('.chimera-settings-categories').addEventListener('click', event => {
|
||
const button = event.target.closest('[data-settings-category]');
|
||
if (!button) return;
|
||
activeCategory = button.dataset.settingsCategory;
|
||
navigator.querySelectorAll('[data-settings-category]').forEach(candidate => candidate.setAttribute('aria-selected', String(candidate === button)));
|
||
applyFilter(input.value);
|
||
});
|
||
input.addEventListener('input', event => applyFilter(event.target.value));
|
||
clearButton.addEventListener('click', () => {
|
||
input.value = '';
|
||
applyFilter('');
|
||
input.focus();
|
||
});
|
||
jump.addEventListener('focus', syncSections);
|
||
jump.addEventListener('pointerdown', syncSections);
|
||
jump.addEventListener('change', event => {
|
||
const section = sections.find(item => item.id === event.target.value);
|
||
if (!section) return;
|
||
input.value = '';
|
||
activeCategory = section.category;
|
||
navigator.querySelectorAll('[data-settings-category]').forEach(candidate => candidate.setAttribute('aria-selected', String(candidate.dataset.settingsCategory === activeCategory)));
|
||
applyFilter('');
|
||
section.card.scrollIntoView({
|
||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||
block: 'center',
|
||
});
|
||
event.target.value = '';
|
||
});
|
||
window.addEventListener('chimera:language-changed', () => {
|
||
syncSections();
|
||
applyFilter(input.value);
|
||
});
|
||
window.setTimeout(syncSections, 0);
|
||
}
|
||
|
||
function setExpandedGroup(groupId, expanded) {
|
||
if (expanded) {
|
||
for (const openGroup of [...expandedGroups]) {
|
||
if (openGroup !== groupId) setExpandedGroup(openGroup, false);
|
||
}
|
||
}
|
||
if (expanded) expandedGroups.add(groupId); else expandedGroups.delete(groupId);
|
||
const domain = [...document.querySelectorAll('[data-nav-group]')].find(item => item.dataset.navGroup === groupId);
|
||
if (!domain) return;
|
||
const button = domain.querySelector('[data-group-toggle]');
|
||
const routes = domain.querySelector('[data-group-routes]');
|
||
if (button) button.setAttribute('aria-expanded', String(expanded));
|
||
if (routes) routes.hidden = !expanded;
|
||
domain.classList.toggle('expanded', expanded);
|
||
}
|
||
|
||
function updateSidebar(route) {
|
||
const group = groupById.get(route.group);
|
||
if (group && group.id !== 'home') setExpandedGroup(group.id, true);
|
||
document.querySelectorAll('[data-route]:not([data-payload-stage])').forEach(element => {
|
||
const active = element.dataset.route === route.id;
|
||
element.classList.toggle('active', active);
|
||
if (element.matches('a,button')) {
|
||
if (active) element.setAttribute('aria-current', 'page');
|
||
else element.removeAttribute('aria-current');
|
||
}
|
||
});
|
||
updatePayloadWorkspaceStage(route.id);
|
||
document.querySelectorAll('[data-nav-group]').forEach(element => element.classList.toggle('active', element.dataset.navGroup === route.group));
|
||
document.querySelectorAll('#chimera-companion-dock [data-companion-group]').forEach(element => {
|
||
const active = element.dataset.route === 'library/games'
|
||
? route.id === 'library/games'
|
||
: element.dataset.companionGroup === route.group;
|
||
element.classList.toggle('active', active);
|
||
if (active) element.setAttribute('aria-current', 'page');
|
||
else element.removeAttribute('aria-current');
|
||
});
|
||
document.getElementById('chimera-companion-more')?.classList.toggle(
|
||
'active',
|
||
!['home', 'library', 'console'].includes(route.group),
|
||
);
|
||
}
|
||
|
||
function defaultPayloadStage(routeId) {
|
||
if (routeId === 'payloads/updates') return 'review';
|
||
if (routeId === 'payloads/boot') return 'boot';
|
||
if (routeId === 'payloads/installed') return 'discover';
|
||
return null;
|
||
}
|
||
|
||
function updatePayloadWorkspaceStage(routeId, requestedStage = null) {
|
||
const currentButton = document.querySelector(`[data-payload-stage="${activePayloadStage}"]`);
|
||
const stage = requestedStage
|
||
|| (currentButton?.dataset.route === routeId ? activePayloadStage : defaultPayloadStage(routeId));
|
||
if (!stage) return;
|
||
activePayloadStage = stage;
|
||
const stages = ['discover', 'review', 'stage', 'boot'];
|
||
const currentIndex = stages.indexOf(stage);
|
||
document.querySelectorAll('[data-payload-stage]').forEach(button => {
|
||
const buttonIndex = stages.indexOf(button.dataset.payloadStage);
|
||
const current = button.dataset.payloadStage === stage;
|
||
button.classList.toggle('is-current', current);
|
||
button.classList.toggle('is-complete', buttonIndex >= 0 && buttonIndex < currentIndex);
|
||
button.setAttribute('aria-pressed', String(current));
|
||
if (current) button.setAttribute('aria-current', 'step');
|
||
else button.removeAttribute('aria-current');
|
||
});
|
||
}
|
||
|
||
function localizeSidebar() {
|
||
contract.routes.forEach(route => {
|
||
document.querySelectorAll(`.chimera-route-link[data-route="${route.id}"]`).forEach(link => {
|
||
const label = link.querySelector(':scope > span:first-child');
|
||
if (label) label.textContent = routeLabel(route);
|
||
link.title = routeDescription(route);
|
||
});
|
||
});
|
||
contract.groups.forEach(group => {
|
||
const label = document.querySelector(`[data-nav-group="${group.id}"] .chimera-domain-button > span:not(.chimera-domain-chevron)`);
|
||
if (label) label.textContent = groupLabel(group);
|
||
});
|
||
const aboutLabel = document.querySelector('#sidebar-donate .donate-label');
|
||
if (aboutLabel) aboutLabel.textContent = tr('navigation.about', 'About Chimera');
|
||
}
|
||
|
||
function updateRouteHeader(route) {
|
||
const group = groupById.get(route.group);
|
||
const localizedGroup = groupLabel(group);
|
||
const localizedLabel = routeLabel(route);
|
||
const localizedDescription = routeDescription(route);
|
||
const breadcrumbs = document.getElementById('chimera-breadcrumbs');
|
||
if (breadcrumbs) breadcrumbs.innerHTML = route.id === 'home'
|
||
? '<span>Home</span>'
|
||
: `<button type="button" data-route="${escapeText(group.defaultRoute)}">${escapeText(localizedGroup)}</button><span aria-hidden="true">/</span><span>${escapeText(localizedLabel)}</span>`;
|
||
const title = document.getElementById('chimera-route-title');
|
||
const description = document.getElementById('chimera-route-description');
|
||
const risk = document.getElementById('chimera-route-risk');
|
||
if (title) title.textContent = localizedLabel;
|
||
if (description) description.textContent = localizedDescription;
|
||
if (risk) risk.hidden = route.risk !== 'confirm';
|
||
document.title = `${localizedLabel} — Chimera`;
|
||
}
|
||
|
||
function clearRouteFocus() {
|
||
document.querySelectorAll('[data-route-hidden="true"]').forEach(element => element.removeAttribute('data-route-hidden'));
|
||
document.querySelectorAll('[data-route-focus]').forEach(element => element.removeAttribute('data-route-focus'));
|
||
}
|
||
|
||
function hideElement(element) {
|
||
if (element) element.setAttribute('data-route-hidden', 'true');
|
||
}
|
||
|
||
function showOnlyPageChildren(pageId, allowedIds) {
|
||
const page = document.getElementById(pageId);
|
||
if (!page) return;
|
||
const allowed = new Set(allowedIds);
|
||
[...page.children].forEach(child => {
|
||
if (child.classList.contains('page-header') || child.hasAttribute('data-route-persistent')) return;
|
||
if (!child.id || !allowed.has(child.id)) hideElement(child);
|
||
});
|
||
}
|
||
|
||
function applyJailbreakFocus(focus) {
|
||
if (!focus) return;
|
||
const profiles = {
|
||
payloads: ['ps5-payload-manager-card', 'console-services-card', 'payload-update-card', 'payload-manager-card'],
|
||
boot: ['payload-manager-card', 'y2jb-generator-card', 'bdjb-generator-card'],
|
||
power: ['system-state-card'],
|
||
};
|
||
showOnlyPageChildren('page-jailbreak', profiles[focus] || profiles.payloads);
|
||
const workspaceNav = document.querySelector('#page-jailbreak > .payload-workspace-nav');
|
||
if (workspaceNav && focus === 'power') hideElement(workspaceNav);
|
||
const sectionVisibility = {
|
||
payloads: ['payload-local-section', 'payload-sender-section'],
|
||
boot: ['payload-autoloader-section', 'autoloader-snapshots-section'],
|
||
};
|
||
if (sectionVisibility[focus]) {
|
||
const visible = new Set(sectionVisibility[focus]);
|
||
document.querySelectorAll('#payload-manager-card > .jb-section').forEach(section => {
|
||
if (!visible.has(section.id)) hideElement(section);
|
||
});
|
||
}
|
||
document.getElementById('page-jailbreak')?.setAttribute('data-route-focus', focus);
|
||
}
|
||
|
||
function applyReliabilityFocus(focus) {
|
||
if (!focus) return;
|
||
const visibleIds = {
|
||
replication: ['operations-reliability-warning', 'operations-replication-card'],
|
||
monitoring: ['operations-monitoring-card'],
|
||
security: ['operations-sessions-card', 'operations-password-card'],
|
||
}[focus];
|
||
if (!visibleIds) return;
|
||
const visible = new Set(visibleIds);
|
||
document.querySelectorAll('[data-operations-panel="reliability"] .card[id]').forEach(card => {
|
||
if (!visible.has(card.id)) hideElement(card);
|
||
});
|
||
document.querySelectorAll('[data-operations-panel="reliability"] .operations-two-column').forEach(column => {
|
||
const cards = [...column.querySelectorAll(':scope > .card')];
|
||
if (cards.length && cards.every(card => card.hasAttribute('data-route-hidden'))) hideElement(column);
|
||
});
|
||
document.querySelector('[data-operations-panel="reliability"]')?.setAttribute('data-route-focus', focus);
|
||
}
|
||
|
||
function applyRouteFocus(route) {
|
||
if (route.page === 'jailbreak') return applyJailbreakFocus(route.focus);
|
||
if (route.page === 'operations' && route.panel === 'reliability') return applyReliabilityFocus(route.focus);
|
||
}
|
||
|
||
function activatePage(page) {
|
||
document.querySelectorAll('.page').forEach(element => element.classList.toggle('active', element.id === `page-${page}`));
|
||
const isEmbed = page === 'savemgr';
|
||
document.getElementById('content')?.classList.toggle('embed-page-active', isEmbed);
|
||
if (!isEmbed) {
|
||
const content = document.getElementById('content');
|
||
if (content) content.scrollTop = 0;
|
||
}
|
||
closeMobileNav();
|
||
if (typeof syncPinStates === 'function') syncPinStates();
|
||
if (typeof scheduleBackgroundWarm === 'function') scheduleBackgroundWarm(800);
|
||
}
|
||
|
||
function selectEcosystemPanel(tab) {
|
||
document.querySelectorAll('.ecosystem-tab').forEach(button => {
|
||
const active = button.dataset.ecosystemTab === tab;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-selected', String(active));
|
||
button.tabIndex = active ? 0 : -1;
|
||
});
|
||
document.querySelectorAll('.ecosystem-panel').forEach(panel => {
|
||
const active = panel.dataset.ecosystemPanel === tab;
|
||
panel.classList.toggle('active', active);
|
||
panel.hidden = !active;
|
||
});
|
||
}
|
||
|
||
async function loadEcosystemRoute(route, force) {
|
||
const wasStale = force || pageIsStale('ecosystem');
|
||
window.chimeraRequestedEcosystemTab = route.tab;
|
||
await ensurePageReady('ecosystem', { force: !!force });
|
||
selectEcosystemPanel(route.tab);
|
||
if (!wasStale && typeof loadEcosystemTab === 'function') await loadEcosystemTab(route.tab);
|
||
}
|
||
|
||
async function loadOperationsRoute(route, force) {
|
||
await ensurePageReady('operations', { force: !!force });
|
||
document.querySelectorAll('.operations-tab').forEach(button => {
|
||
const active = button.dataset.operationsTab === route.panel;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-selected', String(active));
|
||
button.tabIndex = active ? 0 : -1;
|
||
});
|
||
document.querySelectorAll('.operations-panel').forEach(panel => {
|
||
const active = panel.dataset.operationsPanel === route.panel;
|
||
panel.classList.toggle('active', active);
|
||
panel.hidden = !active;
|
||
});
|
||
if (route.panel === 'shadowmount' && typeof loadOperationsShadowMount === 'function') await loadOperationsShadowMount();
|
||
if (route.panel === 'packages' && typeof loadOperationsPackages === 'function') await loadOperationsPackages();
|
||
if (route.panel === 'reliability' && typeof loadOperationsReliability === 'function') await loadOperationsReliability({ focus: route.focus });
|
||
if (route.panel === 'audit' && typeof loadOperationsAudit === 'function') await loadOperationsAudit();
|
||
if (route.panel === 'diagnostics' && typeof loadKlog === 'function') await loadKlog();
|
||
}
|
||
|
||
async function hydrateRoute(route, force) {
|
||
if (route.page === 'ecosystem') await loadEcosystemRoute(route, force);
|
||
else if (route.page === 'operations') await loadOperationsRoute(route, force);
|
||
else await ensurePageReady(route.page, { force: !!force });
|
||
}
|
||
|
||
function writeHistory(routeId, mode) {
|
||
const url = routeUrl(routeId);
|
||
if (mode === 'push') window.history.pushState({ chimeraRoute: routeId }, '', url);
|
||
if (mode === 'replace') window.history.replaceState({ chimeraRoute: routeId }, '', url);
|
||
}
|
||
|
||
async function navigateRoute(value, options = {}) {
|
||
const routeId = normalizeRoute(value);
|
||
const route = routeById.get(routeId);
|
||
const historyMode = options.history ?? 'push';
|
||
if (!route) return;
|
||
if (currentRouteId === routeId && !options.force) {
|
||
closeMobileNav();
|
||
if (historyMode === 'replace') writeHistory(routeId, 'replace');
|
||
return;
|
||
}
|
||
|
||
const sequence = ++navigationSequence;
|
||
clearRouteFocus();
|
||
currentRouteId = routeId;
|
||
if (historyMode !== 'none') writeHistory(routeId, historyMode);
|
||
updateSidebar(route);
|
||
updateRouteHeader(route);
|
||
// Apply deep-route disclosure while the destination page is still hidden.
|
||
// This prevents unrelated Reliability cards from flashing for one frame.
|
||
applyRouteFocus(route);
|
||
activatePage(route.page);
|
||
document.body.setAttribute('data-chimera-route', route.id);
|
||
|
||
const refresh = document.getElementById('chimera-route-refresh');
|
||
const availability = document.getElementById('chimera-route-availability');
|
||
if (availability) availability.hidden = true;
|
||
if (refresh) { refresh.disabled = true; refresh.textContent = tr('common.loading', 'Loading…'); }
|
||
try {
|
||
await window.chimeraI18nReady;
|
||
if (sequence !== navigationSequence) return;
|
||
await hydrateRoute(route, options.force);
|
||
if (sequence !== navigationSequence) return;
|
||
applyRouteFocus(route);
|
||
} catch (error) {
|
||
if (typeof isExpectedUnavailable === 'function' && isExpectedUnavailable(error)) {
|
||
console.info(`[navigation] ${route.id} temporarily unavailable: ${error.message}`);
|
||
if (availability) {
|
||
availability.textContent = `${tr('navigation.unavailable', 'Unavailable')}: ${friendlyMessage(error?.message || error)}`;
|
||
availability.hidden = false;
|
||
}
|
||
} else {
|
||
console.error(`[navigation] ${route.id}`, error);
|
||
const failure = `${tr('navigation.loadFailed', 'Could not fully load')} ${routeLabel(route)}: ${friendlyMessage(error?.message || error)}`;
|
||
if (availability) {
|
||
availability.textContent = `${failure}. ${tr('navigation.retryHint', 'Use Refresh to retry.')}`;
|
||
availability.hidden = false;
|
||
} else if (typeof showToast === 'function') {
|
||
showToast(failure, 'error', 9000);
|
||
}
|
||
}
|
||
} finally {
|
||
if (sequence === navigationSequence && refresh) { refresh.disabled = false; refresh.innerHTML = `↻ <span data-i18n="common.refresh">${escapeText(tr('common.refresh', 'Refresh'))}</span>`; }
|
||
}
|
||
}
|
||
|
||
function openCommandPalette() {
|
||
const overlay = document.getElementById('chimera-command-overlay');
|
||
if (!overlay) return;
|
||
document.body.classList.add('chimera-modal-open');
|
||
const input = document.getElementById('chimera-command-input');
|
||
input.value = '';
|
||
document.getElementById('chimera-command-clear')?.setAttribute('hidden', '');
|
||
renderCommandResults('');
|
||
if (typeof openAccessibleDialog === 'function') openAccessibleDialog(overlay, input);
|
||
else { overlay.hidden = false; requestAnimationFrame(() => input.focus()); }
|
||
}
|
||
|
||
function closeCommandPalette() {
|
||
const overlay = document.getElementById('chimera-command-overlay');
|
||
if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog(overlay);
|
||
else if (overlay) overlay.hidden = true;
|
||
if (document.getElementById('chimera-jobs-overlay')?.hidden !== false) document.body.classList.remove('chimera-modal-open');
|
||
}
|
||
|
||
function routeMatches(route, query) {
|
||
const group = groupById.get(route.group);
|
||
return [route.label, routeLabel(route), route.description, routeDescription(route), route.id, group?.label, groupLabel(group), ...(route.keywords || [])].join(' ').toLocaleLowerCase(activeLocale()).includes(query);
|
||
}
|
||
|
||
function commandRouteMarkup(routes) {
|
||
if (!routes.length) return '';
|
||
return `<div class="chimera-command-section-label">${escapeText(tr('navigation.search.features', 'Features'))}</div>${routes.map(route => {
|
||
const group = groupById.get(route.group);
|
||
return `<button class="chimera-command-result" type="button" role="option" aria-selected="false" data-command-route="${escapeText(route.id)}"><span class="chimera-command-result-icon">${icon(group.icon)}</span><span><strong>${escapeText(routeLabel(route))}</strong><small>${escapeText(groupLabel(group))} · ${escapeText(routeDescription(route))}</small></span><span class="chimera-command-arrow">↵</span></button>`;
|
||
}).join('')}`;
|
||
}
|
||
|
||
function commandGameMarkup(games) {
|
||
if (!games.length) return '';
|
||
return `<div class="chimera-command-section-label">Games</div>${games.map(game => {
|
||
const name = game.prospero_name || game.title || game.game_id;
|
||
return `<button class="chimera-command-result" type="button" role="option" aria-selected="false" data-command-game="${escapeText(game.game_id)}"><span class="chimera-command-game-cover">${game.prospero_icon_url ? `<img src="${escapeText(game.prospero_icon_url)}" alt="" loading="lazy"/>` : '◫'}</span><span><strong>${escapeText(name)}</strong><small>${escapeText(game.game_id)}${game.prospero_publisher ? ` · ${escapeText(game.prospero_publisher)}` : ''}</small></span><span class="chimera-command-arrow">↵</span></button>`;
|
||
}).join('')}`;
|
||
}
|
||
|
||
function recentCommandQueries() {
|
||
try {
|
||
const value = JSON.parse(localStorage.getItem('chimera.search.recent') || '[]');
|
||
return Array.isArray(value) ? value.filter(item => typeof item === 'string' && item.trim()).slice(0, 6) : [];
|
||
} catch (_) { return []; }
|
||
}
|
||
|
||
function rememberCommandQuery(value) {
|
||
const query = String(value || '').trim();
|
||
if (query.length < 2) return;
|
||
const recent = [query, ...recentCommandQueries().filter(item => item.toLocaleLowerCase(activeLocale()) !== query.toLocaleLowerCase(activeLocale()))].slice(0, 6);
|
||
localStorage.setItem('chimera.search.recent', JSON.stringify(recent));
|
||
}
|
||
|
||
function recentCommandMarkup() {
|
||
const recent = recentCommandQueries();
|
||
if (!recent.length) return '';
|
||
return `<div class="chimera-command-section-label">${escapeText(tr('navigation.search.recent', 'Recent searches'))}</div><div class="chimera-command-recent">${recent.map(query => `<button type="button" data-command-query="${escapeText(query)}">${escapeText(query)}</button>`).join('')}</div>`;
|
||
}
|
||
|
||
function resetCommandSelection() {
|
||
const first = document.querySelector('#chimera-command-results .chimera-command-result');
|
||
if (first) {
|
||
first.classList.add('selected');
|
||
first.setAttribute('aria-selected', 'true');
|
||
}
|
||
}
|
||
|
||
function updateCommandStatus(message, busy = false) {
|
||
const results = document.getElementById('chimera-command-results');
|
||
const status = document.getElementById('chimera-command-status');
|
||
if (results) results.setAttribute('aria-busy', String(busy));
|
||
if (status) status.textContent = message;
|
||
}
|
||
|
||
async function getCommandGames(query = '') {
|
||
if (!window.pork.isWebMode && Array.isArray(state?.games) && state.games.length) return state.games;
|
||
const cacheKey = String(query || '').trim().toLocaleLowerCase(activeLocale());
|
||
if (commandGameCache?.key === cacheKey && Date.now() - commandGameCacheAt < 60_000) return commandGameCache.games;
|
||
const result = await window.pork.listGames(cacheKey ? { search: cacheKey, limit: 24 } : { limit: 24 });
|
||
commandGameCache = { key: cacheKey, games: Array.isArray(result) ? result : (result?.games || []) };
|
||
commandGameCacheAt = Date.now();
|
||
return commandGameCache.games;
|
||
}
|
||
|
||
async function renderCommandResults(value) {
|
||
const results = document.getElementById('chimera-command-results');
|
||
if (!results) return;
|
||
const query = String(value || '').trim().toLocaleLowerCase(activeLocale());
|
||
const sequence = ++commandSearchSequence;
|
||
const clearButton = document.getElementById('chimera-command-clear');
|
||
if (clearButton) clearButton.hidden = !query;
|
||
const availableRoutes = contract.routes.filter(route => expertMode || !route.expert);
|
||
const routes = (query ? availableRoutes.filter(route => routeMatches(route, query)) : availableRoutes.filter(route => ['home', 'library/games', 'console/status', 'payloads/installed', 'tools/transfers', 'settings/general'].includes(route.id))).slice(0, 12);
|
||
const recentMarkup = query ? '' : recentCommandMarkup();
|
||
results.innerHTML = `${recentMarkup}${commandRouteMarkup(routes)}` || `<p class="chimera-command-empty">${escapeText(tr('navigation.search.noFeature', 'No feature found. Keep typing to search games.'))}</p>`;
|
||
resetCommandSelection();
|
||
if (query.length < 2) {
|
||
updateCommandStatus(query
|
||
? `${routes.length} ${tr('navigation.search.featuresLower', 'features')} · ${tr('navigation.search.oneMore', 'type 1 more character for games')}`
|
||
: `${routes.length} ${tr('navigation.search.quickRoutes', 'quick routes')}`);
|
||
return;
|
||
}
|
||
updateCommandStatus(`${routes.length} ${tr('navigation.search.featuresLower', 'features')} · ${tr('navigation.search.searchingGames', 'searching games…')}`, true);
|
||
try {
|
||
const games = (await getCommandGames(query)).filter(game => [game.prospero_name, game.title, game.game_id, game.prospero_publisher].filter(Boolean).join(' ').toLocaleLowerCase(activeLocale()).includes(query)).slice(0, 8);
|
||
if (sequence !== commandSearchSequence) return;
|
||
results.innerHTML = `${commandRouteMarkup(routes)}${commandGameMarkup(games)}` || `<p class="chimera-command-empty">${escapeText(tr('navigation.search.noMatches', 'No matches found.'))}</p>`;
|
||
resetCommandSelection();
|
||
updateCommandStatus(`${routes.length + games.length} ${tr('navigation.search.results', 'results')}`);
|
||
} catch (_) {
|
||
if (!routes.length && sequence === commandSearchSequence) results.innerHTML = `<p class="chimera-command-empty">${escapeText(tr('navigation.search.gamesUnavailable', 'Game search is currently unavailable.'))}</p>`;
|
||
if (sequence === commandSearchSequence) updateCommandStatus(routes.length
|
||
? `${routes.length} ${tr('navigation.search.featuresLower', 'features')} · ${tr('navigation.search.gamesUnavailableShort', 'games unavailable')}`
|
||
: tr('navigation.search.noResults', 'No results'));
|
||
}
|
||
}
|
||
|
||
async function activateCommandResult(button) {
|
||
if (!button) return;
|
||
rememberCommandQuery(document.getElementById('chimera-command-input')?.value);
|
||
closeCommandPalette();
|
||
if (button.dataset.commandRoute) return navigateRoute(button.dataset.commandRoute);
|
||
if (button.dataset.commandGame) {
|
||
await navigateRoute('library/games');
|
||
const input = document.getElementById('games-search');
|
||
if (input) {
|
||
input.value = button.dataset.commandGame;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
}
|
||
}
|
||
|
||
function moveCommandSelection(direction) {
|
||
const buttons = [...document.querySelectorAll('#chimera-command-results .chimera-command-result')];
|
||
if (!buttons.length) return;
|
||
let index = buttons.findIndex(button => button.classList.contains('selected'));
|
||
buttons.forEach(button => {
|
||
button.classList.remove('selected');
|
||
button.setAttribute('aria-selected', 'false');
|
||
});
|
||
index = (index + direction + buttons.length) % buttons.length;
|
||
buttons[index].classList.add('selected');
|
||
buttons[index].setAttribute('aria-selected', 'true');
|
||
buttons[index].scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
function transferStatusLabel(status) {
|
||
const fallback = {
|
||
queued: 'Queued', active: 'Active', done: 'Completed', error: 'Error',
|
||
cancelled: 'Cancelled', interrupted: 'Interrupted', retrying: 'Retry scheduled',
|
||
}[status] || status || 'Unknown';
|
||
return tr(`navigation.jobs.status.${status || 'unknown'}`, fallback);
|
||
}
|
||
|
||
function renderJobs(stateValue) {
|
||
currentTransferState = stateValue || currentTransferState;
|
||
const active = Number(currentTransferState.activeCount || 0);
|
||
const queued = Number(currentTransferState.queuedCount || 0);
|
||
const count = active + queued;
|
||
const homeTransferTruth = document.getElementById('home-truth-transfers');
|
||
if (homeTransferTruth) {
|
||
homeTransferTruth.textContent = active
|
||
? `${active} active`
|
||
: queued
|
||
? `${queued} queued`
|
||
: currentTransferState.paused
|
||
? 'Queue paused'
|
||
: 'No active work';
|
||
}
|
||
const badge = document.getElementById('chimera-job-count');
|
||
const navBadge = document.getElementById('nav-transfer-badge');
|
||
if (badge) {
|
||
const total = count + currentActionCount;
|
||
badge.hidden = total === 0;
|
||
badge.textContent = String(total);
|
||
}
|
||
if (navBadge) {
|
||
navBadge.hidden = count === 0;
|
||
navBadge.textContent = String(count);
|
||
}
|
||
const companionBadge = document.getElementById('chimera-companion-count');
|
||
if (companionBadge) {
|
||
const total = count + currentActionCount;
|
||
companionBadge.hidden = total === 0;
|
||
companionBadge.textContent = String(total);
|
||
}
|
||
document.querySelector('.chimera-jobs-pulse')?.classList.toggle('active', active > 0);
|
||
const summary = document.getElementById('chimera-jobs-summary');
|
||
const list = document.getElementById('chimera-jobs-list');
|
||
if (summary) summary.innerHTML = `<div><strong>${active}</strong><span>${escapeText(tr('navigation.jobs.active', 'active'))}</span></div><div><strong>${queued}</strong><span>${escapeText(tr('navigation.jobs.queued', 'queued'))}</span></div><div><strong>${escapeText(currentTransferState.paused ? tr('common.yes', 'Yes') : tr('common.no', 'No'))}</strong><span>${escapeText(tr('navigation.jobs.paused', 'paused'))}</span></div>`;
|
||
if (!list) return;
|
||
const jobs = (currentTransferState.jobs || []).filter(job => job.status !== 'cancelled').slice(-20).reverse();
|
||
list.innerHTML = jobs.length ? jobs.map(job => {
|
||
const progress = Math.max(0, Math.min(100, Number(job.progress?.percent || (job.status === 'done' ? 100 : 0))));
|
||
return `<article class="chimera-job chimera-job-${escapeText(job.status)}"><div><strong>${escapeText(job.label || job.progress?.file || 'Transfer')}</strong><span>${escapeText(transferStatusLabel(job.status))}${job.progress?.file ? ` · ${escapeText(job.progress.file)}` : ''}</span></div><b>${Math.round(progress)}%</b><div class="chimera-job-progress"><span style="width:${progress}%"></span></div>${job.error ? `<small>${escapeText(job.error)}</small>` : ''}</article>`;
|
||
}).join('') : `<div class="chimera-drawer-empty"><strong>${escapeText(tr('navigation.jobs.emptyTitle', 'No transfers'))}</strong><span>${escapeText(tr('navigation.jobs.emptyDetail', 'New uploads and downloads will appear here automatically.'))}</span></div>`;
|
||
}
|
||
|
||
function actionRoute(action = {}) {
|
||
if (action.tab) return contract.routes.find(route => route.page === 'ecosystem' && route.tab === action.tab)?.id || 'console/status';
|
||
if (action.panel) return contract.routes.find(route => route.page === 'operations' && route.panel === action.panel)?.id || 'settings/automation';
|
||
return contract.legacyAliases[action.page] || (routeById.has(action.page) ? action.page : 'console/status');
|
||
}
|
||
|
||
function renderActions(packet) {
|
||
const list = document.getElementById('chimera-action-list');
|
||
if (!list) return;
|
||
const report = packet?.report || null;
|
||
const alerts = (report?.alerts || []).filter(alert => !alert.acknowledged).slice(0, 12);
|
||
currentActionCount = alerts.length;
|
||
const badge = document.getElementById('chimera-job-count');
|
||
if (badge) {
|
||
const total = Number(currentTransferState.activeCount || 0) + Number(currentTransferState.queuedCount || 0) + currentActionCount;
|
||
badge.hidden = total === 0;
|
||
badge.textContent = String(total);
|
||
}
|
||
const companionBadge = document.getElementById('chimera-companion-count');
|
||
if (companionBadge) {
|
||
const total = Number(currentTransferState.activeCount || 0) + Number(currentTransferState.queuedCount || 0) + currentActionCount;
|
||
companionBadge.hidden = total === 0;
|
||
companionBadge.textContent = String(total);
|
||
}
|
||
if (!report) {
|
||
list.innerHTML = `<div class="chimera-drawer-empty"><strong>${escapeText(tr('navigation.actionCenter.notScanned', 'No health scan yet'))}</strong><span>${escapeText(tr('navigation.actionCenter.notScannedDetail', 'Open Console health to run the first bounded scan.'))}</span></div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = alerts.length ? alerts.map(alert => {
|
||
const count = String(alert.title || '').match(/\d+(?:\/\d+)?/)?.[0] || '';
|
||
const values = { count };
|
||
const translatedTitle = window.tf?.(`navigation.alerts.${alert.id}.title`, alert.title, values) || alert.title;
|
||
const translatedDetail = window.tf?.(`navigation.alerts.${alert.id}.detail`, alert.detail, values) || alert.detail;
|
||
return `
|
||
<article class="chimera-action chimera-action-${escapeText(alert.severity)}">
|
||
<span class="chimera-action-dot" aria-hidden="true"></span>
|
||
<div><strong>${escapeText(translatedTitle)}</strong><span>${escapeText(translatedDetail)}</span></div>
|
||
<button class="btn btn-sm" type="button" data-route="${escapeText(actionRoute(alert.action || {}))}">${escapeText(tr('common.open', 'Open'))}</button>
|
||
</article>`;
|
||
}).join('') : `<div class="chimera-action-clear"><span aria-hidden="true">✓</span><div><strong>${escapeText(tr('navigation.actionCenter.clear', 'Nothing needs attention'))}</strong><span>${escapeText(tr('navigation.actionCenter.clearDetail', 'No open alerts were found in the latest health scan.'))}</span></div></div>`;
|
||
}
|
||
|
||
async function openJobsDrawer() {
|
||
const overlay = document.getElementById('chimera-jobs-overlay');
|
||
if (!overlay) return;
|
||
document.body.classList.add('chimera-modal-open');
|
||
const [transfers, health] = await Promise.allSettled([
|
||
window.pork.transferState(),
|
||
window.pork.ecosystemHealthLatest(),
|
||
]);
|
||
renderJobs(transfers.status === 'fulfilled' ? transfers.value : currentTransferState);
|
||
renderActions(health.status === 'fulfilled' ? health.value : null);
|
||
const closeButton = overlay.querySelector('[data-jobs-close]');
|
||
if (typeof openAccessibleDialog === 'function') openAccessibleDialog(overlay, closeButton);
|
||
else { overlay.hidden = false; requestAnimationFrame(() => closeButton?.focus()); }
|
||
}
|
||
|
||
function closeJobsDrawer() {
|
||
const overlay = document.getElementById('chimera-jobs-overlay');
|
||
if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog(overlay);
|
||
else if (overlay) overlay.hidden = true;
|
||
if (document.getElementById('chimera-command-overlay')?.hidden !== false) document.body.classList.remove('chimera-modal-open');
|
||
}
|
||
|
||
function openAboutDialog() {
|
||
const overlay = document.getElementById('chimera-about-overlay');
|
||
if (!overlay) return;
|
||
const closeButton = overlay.querySelector('[data-about-close]');
|
||
if (typeof openAccessibleDialog === 'function') openAccessibleDialog(overlay, closeButton);
|
||
else { overlay.hidden = false; requestAnimationFrame(() => closeButton?.focus()); }
|
||
}
|
||
|
||
function closeAboutDialog() {
|
||
const overlay = document.getElementById('chimera-about-overlay');
|
||
if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog(overlay);
|
||
else if (overlay) overlay.hidden = true;
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.addEventListener('click', async event => {
|
||
const routeTarget = event.target.closest('[data-route]');
|
||
if (routeTarget) {
|
||
event.preventDefault();
|
||
closeCommandPalette();
|
||
closeJobsDrawer();
|
||
closeAboutDialog();
|
||
const requestedStage = routeTarget.dataset.payloadStage
|
||
|| (!routeTarget.closest('.payload-workspace-nav') ? defaultPayloadStage(routeTarget.dataset.route) : null);
|
||
if (requestedStage) activePayloadStage = requestedStage;
|
||
await navigateRoute(routeTarget.dataset.route);
|
||
const focusTarget = routeTarget.dataset.focusTarget
|
||
? document.getElementById(routeTarget.dataset.focusTarget)
|
||
: null;
|
||
if (focusTarget) {
|
||
focusTarget.scrollIntoView({
|
||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||
block: 'center',
|
||
});
|
||
requestAnimationFrame(() => focusTarget.focus({ preventScroll: true }));
|
||
}
|
||
if (requestedStage) updatePayloadWorkspaceStage(routeTarget.dataset.route, requestedStage);
|
||
const payloadTarget = routeTarget.dataset.payloadTarget
|
||
? document.getElementById(routeTarget.dataset.payloadTarget)
|
||
: null;
|
||
if (payloadTarget) {
|
||
payloadTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
payloadTarget.classList.remove('payload-workspace-highlight');
|
||
requestAnimationFrame(() => payloadTarget.classList.add('payload-workspace-highlight'));
|
||
window.setTimeout(() => payloadTarget.classList.remove('payload-workspace-highlight'), 1800);
|
||
}
|
||
return;
|
||
}
|
||
const groupToggle = event.target.closest('[data-group-toggle]');
|
||
if (groupToggle) {
|
||
const groupId = groupToggle.dataset.groupToggle;
|
||
setExpandedGroup(groupId, !expandedGroups.has(groupId));
|
||
}
|
||
const commandResult = event.target.closest('[data-command-route], [data-command-game]');
|
||
if (commandResult) activateCommandResult(commandResult);
|
||
const commandQuery = event.target.closest('[data-command-query]');
|
||
if (commandQuery) {
|
||
const input = document.getElementById('chimera-command-input');
|
||
if (input) {
|
||
input.value = commandQuery.dataset.commandQuery;
|
||
renderCommandResults(input.value);
|
||
input.focus();
|
||
}
|
||
}
|
||
if (event.target.closest('[data-command-close]') || event.target.id === 'chimera-command-overlay') closeCommandPalette();
|
||
if (event.target.closest('[data-jobs-close]') || event.target.id === 'chimera-jobs-overlay') closeJobsDrawer();
|
||
if (event.target.closest('[data-about-close]') || event.target.id === 'chimera-about-overlay') closeAboutDialog();
|
||
});
|
||
|
||
document.getElementById('chimera-command-open')?.addEventListener('click', openCommandPalette);
|
||
document.getElementById('chimera-jobs-open')?.addEventListener('click', openJobsDrawer);
|
||
document.getElementById('chimera-companion-actions')?.addEventListener('click', openJobsDrawer);
|
||
document.getElementById('chimera-companion-more')?.addEventListener('click', event => {
|
||
document.getElementById('mobile-nav-toggle')?.click();
|
||
event.currentTarget.setAttribute('aria-expanded', String(document.body.classList.contains('mobile-nav-open')));
|
||
});
|
||
document.getElementById('chimera-route-refresh')?.addEventListener('click', () => navigateRoute(currentRouteId, { history: 'none', force: true }));
|
||
document.getElementById('chimera-expert-mode')?.addEventListener('change', async event => {
|
||
expertMode = event.target.checked;
|
||
document.body.classList.toggle('chimera-expert-mode', expertMode);
|
||
await syncExpertPermission();
|
||
window.dispatchEvent(new CustomEvent('chimera:expert-mode-changed', {
|
||
detail: { enabled: expertMode },
|
||
}));
|
||
if (!expertMode && routeById.get(currentRouteId)?.expert) navigateRoute(currentRouteId, { history: 'replace', force: true });
|
||
});
|
||
document.getElementById('chimera-command-input')?.addEventListener('input', event => renderCommandResults(event.target.value));
|
||
document.getElementById('chimera-command-clear')?.addEventListener('click', () => {
|
||
const input = document.getElementById('chimera-command-input');
|
||
if (!input) return;
|
||
input.value = '';
|
||
renderCommandResults('');
|
||
input.focus();
|
||
});
|
||
document.getElementById('chimera-command-input')?.addEventListener('keydown', event => {
|
||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); moveCommandSelection(event.key === 'ArrowDown' ? 1 : -1); }
|
||
if (event.key === 'Enter') { event.preventDefault(); activateCommandResult(document.querySelector('#chimera-command-results .chimera-command-result.selected')); }
|
||
});
|
||
document.addEventListener('keydown', event => {
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); openCommandPalette(); }
|
||
if (event.key === 'Escape') { closeCommandPalette(); closeJobsDrawer(); closeAboutDialog(); }
|
||
});
|
||
const syncFromLocation = () => navigateRoute(routeFromLocation(), { history: 'none' });
|
||
window.addEventListener('popstate', syncFromLocation);
|
||
window.addEventListener('hashchange', syncFromLocation);
|
||
window.addEventListener('chimera:language-changed', () => {
|
||
localizeSidebar();
|
||
const route = routeById.get(currentRouteId);
|
||
if (route) updateRouteHeader(route);
|
||
const input = document.getElementById('chimera-command-input');
|
||
if (input && document.getElementById('chimera-command-overlay')?.hidden === false) renderCommandResults(input.value);
|
||
renderJobs(currentTransferState);
|
||
if (document.getElementById('chimera-jobs-overlay')?.hidden === false) {
|
||
window.pork.ecosystemHealthLatest().then(renderActions).catch(() => renderActions(null));
|
||
}
|
||
});
|
||
window.pork?.on?.('transfer:update', renderJobs);
|
||
}
|
||
|
||
mountSidebar();
|
||
mountShell();
|
||
mountCompanionDock();
|
||
mountHomeCommandCenter();
|
||
mountSettingsNavigator();
|
||
bindEvents();
|
||
window.chimeraOpenAbout = openAboutDialog;
|
||
|
||
const compatibleNavigate = page => navigateRoute(contract.legacyAliases[page] || page);
|
||
navigate = compatibleNavigate;
|
||
window.navigate = compatibleNavigate;
|
||
window.chimeraNavigate = navigateRoute;
|
||
window.chimeraNavigationContract = contract;
|
||
|
||
const initialRoute = routeFromLocation();
|
||
const initialGroup = routeById.get(initialRoute)?.group;
|
||
if (initialGroup && initialGroup !== 'home') expandedGroups.add(initialGroup);
|
||
requestAnimationFrame(() => navigateRoute(initialRoute, { history: 'replace' }));
|
||
window.pork?.transferState?.().then(renderJobs).catch(() => {});
|
||
window.pork?.ecosystemHealthLatest?.().then(renderActions).catch(() => {});
|
||
})();
|