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>
159 lines
5.7 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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,
|
|
};
|
|
})();
|