collabcentral/html/js/app.js
Joseph B. McQueen 2984e8e851 Phase 9: rebuild frontend on a shared layout + fix Monitor Jobs race
The nav link race:
- Every page's header used `<a id="jobLink" href=''>` and the real
  destination was only assigned later, after /info returned. An empty
  href resolves to the current document URL, so clicking "Monitor Jobs"
  on sendMessage before /info completed silently reloaded sendMessage.
- Fixed structurally: nav links now use static relative hrefs baked
  into the HTML ("./sendMessage.html", "./monitorJobs.html"), so the
  destination is correct the moment the DOM parses.

Shared UI:
- New html/css/app.css: design tokens (palette, radius, shadow, font),
  sticky compact top header (bot avatar + label on the left, nav pills
  in the middle, current user on the right, active-page highlight via
  aria-current), card containers, form styling with focus rings,
  DataTables theme overrides, status pills, modal, and responsive
  breakpoints.
- New html/js/app.js: shared browser bootstrap. Parses appName from
  the URL, redirects to OAuth if the id cookie is missing, fetches
  /info once, populates the header, applies aria-current to the
  active nav link, and invokes a per-page onReady callback with
  { info, appName }. Also exports getCookie, escapeHtml, and formatDate
  helpers so each page stops shipping its own copy.

Per-page rewrites:
- sendMessage.html/.js: form now lives in a card, image preview only
  shows when a file is attached, EasyMDE + VirtualSelect styled to
  match the theme, submit is a primary button, confirmation modal
  redesigned. All bootstrap code deleted (delegated to app.js).
- monitorJobs.html/.js: three cards (Running / Scheduled / Completed)
  with themed DataTables. Completed table sorts by start time desc,
  paginates, and searches; message column truncates HTML previews to
  ~120 chars. Empty-state text per table. `jobId` and running-row
  "view" links go to jobDetail via safe relative URLs.
- jobDetail.html/.js: same shared header + card layout; summary grid,
  message preview, and recipient table styled to match the new
  palette.

Sanity checks:
- All 43 helper tests still pass.
- Server boots cleanly on port 3001.
- Curl of sendMessage/monitorJobs/jobDetail all return 200 with the
  shared header markup.
- /CollabCentral/:app/css/app.css and /CollabCentral/:app/js/app.js
  both serve 200 (shared static mount is per-bot as expected).
- No local href in any page is empty; every nav target resolves at
  parse time.
- /info and requireBot 404 gate unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:36:36 -04:00

159 lines
5.7 KiB
JavaScript

// Shared browser bootstrap for the three CollabCentral pages (sendMessage,
// monitorJobs, jobDetail). Handles the auth cookie check, redirects to the
// OAuth flow when needed, fetches /info once, and populates the shared header
// (bot avatar, bot label, current user name, active-page highlight).
//
// Pages should:
// 1. Include /CollabCentral/<app>/js/app.js *before* their page script.
// 2. Set `<body data-page="send|monitor|detail">` so the header highlights
// the correct nav link.
// 3. Call `CollabCentral.init(onReady)` after DOMContentLoaded (or via a
// <script defer> at the bottom of <body>). `onReady({ info, appName })`
// fires once the user is authenticated AND authorized; otherwise the
// shared unauthorized panel is shown and the callback is skipped.
(function () {
var pathArray = window.location.pathname.split('/');
// Path shape: /CollabCentral/<appName>/<file>
var appName = pathArray[2];
// ---- helpers exposed on window.CollabCentral ---------------------------
function getCookie(name) {
var target = name + '=';
var parts = decodeURIComponent(document.cookie || '').split(';');
for (var i = 0; i < parts.length; i++) {
var c = parts[i].trim();
if (c.indexOf(target) === 0) return c.substring(target.length);
}
return '';
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDate(input) {
if (!input) return '';
try {
var d = new Date(input);
if (isNaN(d.getTime())) return String(input);
return d.toLocaleString();
} catch (e) {
return String(input);
}
}
// ---- header rendering --------------------------------------------------
function highlightActiveNav() {
var page = (document.body && document.body.getAttribute('data-page')) || '';
if (!page) return;
var links = document.querySelectorAll('.appHeader__nav a[data-nav]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-nav') === page) {
links[i].setAttribute('aria-current', 'page');
} else {
links[i].removeAttribute('aria-current');
}
}
}
function renderHeader(info) {
// Bot label + document title
if (info.label) {
var labelEl = document.getElementById('appLabel');
if (labelEl) labelEl.textContent = info.label;
var titleSuffix = (document.body && document.body.getAttribute('data-title-suffix')) || '';
document.title = titleSuffix ? info.label + ' — ' + titleSuffix : info.label;
}
// Bot avatar
var avatarUrl = info.avatarUrl || info.iconUrl;
if (avatarUrl) {
var avatarEl = document.getElementById('appAvatar');
if (avatarEl) avatarEl.style.backgroundImage = 'url(' + JSON.stringify(avatarUrl) + ')';
}
// Favicon
if (info.faviconUrl) {
var fav = document.getElementById('appFavicon');
if (fav) fav.href = info.faviconUrl;
}
// User (from cookie set at OAuth completion)
var userEl = document.getElementById('appUser');
if (userEl) userEl.textContent = getCookie('displayName') || '';
}
function showUnauthorized() {
// Every page includes an #unauthorizedPanel in its markup for this
// exact fallback. Reveal it and hide anything else the page might
// have already shown.
var el = document.getElementById('unauthorizedPanel');
if (el) el.classList.remove('hidden');
}
// ---- redirect to OAuth when no session cookie is present ---------------
function redirectToOAuth() {
// Preserve the pre-existing behavior of appending a random query
// suffix so the browser does not cache the intermediate redirect.
var randomTail = Math.random().toString().substring(2);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(function (res) { return res.text(); })
.then(function (url) {
if (!url) return;
window.location.href = url + randomTail;
})
.catch(function (err) {
console.error('Failed to fetch /authUrl:', err);
});
}
// ---- top-level init ----------------------------------------------------
function init(onReady) {
highlightActiveNav();
if (!getCookie('id')) {
redirectToOAuth();
return;
}
fetch('/CollabCentral/' + appName + '/info')
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (info) {
renderHeader(info);
if (!info.authorized) {
showUnauthorized();
return;
}
if (typeof onReady === 'function') {
onReady({ info: info, appName: appName });
}
})
.catch(function (err) {
console.error('Failed to load /info:', err);
});
}
// ---- expose ------------------------------------------------------------
window.CollabCentral = {
appName: appName,
init: init,
getCookie: getCookie,
escapeHtml: escapeHtml,
formatDate: formatDate,
};
})();