/* Shared app chrome for the Work Package Suite: a project switcher beside the Prime logo and a global search centered in the top bar. One script for every page because there are two generations of top bar — the dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header` (SOP suite, WP creator). We find whichever exists, insert the same markup, and flip a colour set based on how dark the host bar is. Search hits GET /api/search, which scopes results to the projects the signed-in user may access — so this is a convenience, never a way to see another job. Skipped inside an iframe: the WP creator is embedded in the suite page, and a second bar inside the frame would be nonsense. */ (function () { 'use strict'; var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); if (inIframe) return; var SEARCH_MIN = 2; // characters before we ask the server var DEBOUNCE_MS = 180; function el(tag, cls, html) { var n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; } function esc(v) { return String(v == null ? '' : v) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function isDark(node) { try { var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/); if (!m) return false; return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140; } catch (e) { return false; } } // ── where to put the chrome ──────────────────────────────────────────────── // Returns {host, insertBefore} or null. The insertion point matters: on the // dark bar we sit before the spacer (so search takes the middle); on the light // bars we sit between the left block and the right-hand buttons. function findMount() { var appbar = document.querySelector('.wp-appbar'); if (appbar) { return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') }; } var header = document.querySelector('.header'); if (header) { // The suite page wraps its own left/right groups; the creator's bar is a // flat row of buttons whose first button carries margin-left:auto. var right = header.querySelector('.header-right'); if (right) return { host: header, before: right }; var firstBtn = header.querySelector('.btn, button'); return { host: header, before: firstBtn }; } return null; } // ── project switcher ─────────────────────────────────────────────────────── var projects = []; function activeProject() { try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; } } function projectLabel(p) { if (!p) return 'Select a project'; var n = p.name || '(unnamed)'; return p.number ? (p.number + ' — ' + n) : n; } // Switching project reloads the current page with ?project=. Every page // already resolves its project from that param (falling back to the stored // active id), so a reload is both the simplest and the safest route — no page // has to re-hydrate half its state in place. function switchProject(p) { try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {} var url = new URL(location.href); url.searchParams.set('project', p.id); url.hash = ''; location.assign(url.toString()); } function buildProjectSwitcher() { var wrap = el('div', 'wpc-proj'); var btn = el('button', 'wpc-proj-btn'); btn.type = 'button'; btn.setAttribute('aria-haspopup', 'listbox'); btn.setAttribute('aria-expanded', 'false'); btn.title = 'Switch project'; var cur = activeProject(); btn.innerHTML = '' + 'Project' + '' + esc(projectLabel(cur)) + '' + ''; var pop = el('div', 'wpc-pop'); pop.hidden = true; wrap.appendChild(btn); wrap.appendChild(pop); function render() { var curId = (activeProject() || {}).id || ''; var rows = projects.map(function (p) { return ''; }).join(''); pop.innerHTML = '
Switch project
' + (rows || '
No projects you can access yet.
') + '
All projects / new project
'; Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) { item.addEventListener('click', function () { var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0]; if (p) switchProject(p); }); }); } function open() { render(); pop.hidden = false; btn.setAttribute('aria-expanded', 'true'); } function close() { pop.hidden = true; btn.setAttribute('aria-expanded', 'false'); } btn.addEventListener('click', function (e) { e.stopPropagation(); if (pop.hidden) open(); else close(); }); document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); // Refresh the label once the project list (and any active project) is known. wrap.wpcRefresh = function () { var c = activeProject(); var nameEl = btn.querySelector('.wpc-proj-name'); if (nameEl) nameEl.textContent = projectLabel(c); if (!pop.hidden) render(); }; return wrap; } function loadProjects(switcher) { // ProjectData.list() already hits the API and falls back to its local cache // when offline, so there's no second request to make here. var p; try { p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null; } catch (e) { p = null; } if (!p) { p = fetch('/api/projects', { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : []; }); } Promise.resolve(p) .then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); }) .catch(function () {}); } // ── archived-project banner ──────────────────────────────────────────────── // An archived project is still readable and still deep-linkable (?project=), // but every write now 409s. With nothing on the page to say so, that reads as a // silent failure — so the bar, the one thing every page has, carries the warning. // // The state comes from GET /api/projects/, never from "it's missing from the // switcher": absence also means "you have no access to it", which is a different // message. Fails closed and silent — an error means no banner, not a broken page. function activeProjectId() { try { var q = new URLSearchParams(location.search).get('project'); if (q) return q; return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; } catch (e) { return ''; } } function buildArchivedBanner() { var bar = el('div', 'wpc-archived'); bar.setAttribute('role', 'status'); bar.innerHTML = '' + 'Archived project — read-only. ' + 'Unarchive it from the Admin Console to make changes.'; return bar; } function checkArchived(host) { var id = activeProjectId(); if (!id || !host || !host.parentNode) return; var pinned = false; try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {} fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (p) { if (!p || !p.archived) return; // The home page reconciles the stored active project against the (now // archive-filtered) list while this request is in flight, and drops it. If // that happened, the id we asked about is nobody's context any more — // banner-ing it would contradict the picker one line below. A ?project= // deep link is pinned to this page and can't be cleared out from under us. if (!pinned && window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId() !== id) return; // Also on the document element, so the two big apps can gate their own UI // from CSS or a boot check without a second round trip. The server stays // the real gate; this is only there so the UI can agree with it. try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {} if (document.querySelector('.wpc-archived')) return; host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling); }) .catch(function () {}); } // ── global search ────────────────────────────────────────────────────────── function buildSearch() { var wrap = el('div', 'wpc-search'); var box = el('div', 'wpc-search-box'); box.innerHTML = '' + '' + '' + 'Ctrl K'; var pop = el('div', 'wpc-pop'); pop.hidden = true; wrap.appendChild(box); wrap.appendChild(pop); var input = box.querySelector('.wpc-search-input'); var clear = box.querySelector('.wpc-clear'); var timer = null, seq = 0, items = [], activeIx = -1; function close() { pop.hidden = true; activeIx = -1; } function highlight() { Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) { n.classList.toggle('is-active', i === activeIx); if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' }); }); } // A work package lives inside the suite's Creator tab, so open the suite on // that project with the package requested; a SOP opens the SOP tab. function hrefFor(hit) { if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id); if (hit.kind === 'wp') { return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') + '&wp=' + encodeURIComponent(hit.id); } return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || ''); } function go(hit) { if (!hit) return; if (hit.kind === 'project') { var p = projects.filter(function (x) { return x.id === hit.id; })[0]; if (p) { switchProject(p); return; } } // Set the active project only from a full record — writing a stub would // clobber the cached project (name, number, client) other pages read. The // ?project= param in the URL is what actually switches context. var full = projects.filter(function (x) { return x.id === hit.project_id; })[0]; if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} } location.assign(hrefFor(hit)); } function renderResults(data) { items = []; var html = ''; function group(title, rows) { if (!rows.length) return; html += '
' + esc(title) + '
' + rows.join(''); } group('Work packages', (data.wps || []).map(function (w) { items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name }); return ''; })); group('Projects', (data.projects || []).map(function (p) { items.push({ kind: 'project', id: p.id }); return ''; })); group('SOPs', (data.sops || []).map(function (s) { items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name }); return ''; })); if (!items.length) { html = '
Nothing matches “' + esc(data.query || '') + '” in the projects you can access.
'; } pop.innerHTML = html; pop.hidden = false; activeIx = items.length ? 0 : -1; highlight(); Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) { n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); }); n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); }); }); } function run(q) { var mine = ++seq; fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (mine !== seq) return; // a newer keystroke already won if (!data) { close(); return; } renderResults(data); }) .catch(function () { if (mine !== seq) return; pop.innerHTML = '
Search is unavailable offline.
'; pop.hidden = false; }); } input.addEventListener('input', function () { var q = input.value.trim(); clear.hidden = !q; clearTimeout(timer); if (q.length < SEARCH_MIN) { close(); return; } timer = setTimeout(function () { run(q); }, DEBOUNCE_MS); }); input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { close(); input.blur(); return; } if (pop.hidden || !items.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); } else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); } else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); } }); input.addEventListener('focus', function () { if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false; }); clear.addEventListener('click', function () { input.value = ''; clear.hidden = true; close(); input.focus(); }); document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); }); // Ctrl/Cmd-K from anywhere focuses search (matches the tools people already // use). Ignored while typing in another field so it can't steal a shortcut. document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); input.focus(); input.select(); } }); return wrap; } // ── mount ────────────────────────────────────────────────────────────────── function mount() { if (document.querySelector('.wp-chrome')) return; var m = findMount(); if (!m) return; var chrome = el('div', 'wp-chrome'); if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark'); var switcher = buildProjectSwitcher(); chrome.appendChild(switcher); chrome.appendChild(buildSearch()); if (m.before) m.host.insertBefore(chrome, m.before); else m.host.appendChild(chrome); loadProjects(switcher); checkArchived(m.host); window.wpChromeRefresh = function () { switcher.wpcRefresh(); }; // Subscribe rather than keep our own copy of the value. Before this, the label // was rendered once at build time and refreshed only when /api/projects came // back, so selecting a project on the launcher updated the hero and left the bar // reading "Select a project" — that was F1. try { if (window.ProjectData && ProjectData.onActiveChange) { ProjectData.onActiveChange(function () { switcher.wpcRefresh(); }); } } catch (e) {} } // Wait for the auth guard: an unauthenticated page is about to redirect, and // /api/search would 401 anyway. if (window.WP_USER) mount(); else document.addEventListener('wp-auth-ready', mount); })();