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>
This commit is contained in:
Joseph B. McQueen 2026-07-01 19:36:36 -04:00
parent 2b37c4b24f
commit 2984e8e851
9 changed files with 1103 additions and 882 deletions

View file

@ -1,7 +1,7 @@
{
"server": {
"name": "CollabCentral",
"port": "1450"
"port": "1451"
},
"languages": [],
"webex": {

514
html/css/app.css Normal file
View file

@ -0,0 +1,514 @@
/* ============================================================================
CollabCentral shared UI. All three top-level pages (sendMessage, monitorJobs,
jobDetail) render on top of this stylesheet every rule below is meant to
be reusable across pages, and per-page tweaks stay in a small `<style>` at
the top of each HTML file.
============================================================================ */
:root {
--bg: #f8fafc;
--card: #ffffff;
--border: #e5e7eb;
--border-strong: #cbd5e1;
--text: #0f172a;
--text-muted: #64748b;
--text-subtle: #94a3b8;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-soft: #dbeafe;
--success: #15803d;
--success-bg: #dcfce7;
--danger: #b91c1c;
--danger-bg: #fee2e2;
--warning: #a16207;
--warning-bg: #fef9c3;
--radius: 8px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--content-max: 1100px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover { color: var(--accent-hover); text-decoration: underline; }
.hidden { display: none !important; }
/* ------- Top nav ---------------------------------------------------------- */
.appHeader {
position: sticky;
top: 0;
z-index: 10;
background: var(--card);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.appHeader__inner {
max-width: var(--content-max);
margin: 0 auto;
padding: 0.6em 1.25em;
display: flex;
align-items: center;
gap: 1em;
}
.appHeader__brand {
display: flex;
align-items: center;
gap: 0.7em;
min-width: 0;
}
.appHeader__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--border);
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.appHeader__label {
font-weight: 600;
font-size: 1rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.appHeader__nav {
display: flex;
gap: 0.4em;
margin-left: 1em;
}
.appHeader__nav a {
display: inline-block;
padding: 0.4em 0.85em;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
}
.appHeader__nav a:hover {
background: var(--bg);
color: var(--text);
text-decoration: none;
}
.appHeader__nav a[aria-current="page"] {
background: var(--accent-soft);
color: var(--accent);
}
.appHeader__spacer { flex: 1; }
.appHeader__user {
color: var(--text-muted);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 20ch;
}
/* ------- Page container / cards ------------------------------------------ */
.container {
max-width: var(--content-max);
margin: 1.5em auto;
padding: 0 1.25em;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 1.5em;
margin-bottom: 1.5em;
}
.card h3 { margin-top: 0; }
.pageTitle {
margin: 0 0 0.2em;
font-size: 1.35rem;
font-weight: 600;
}
.pageSubtitle {
color: var(--text-muted);
margin: 0 0 1.25em;
font-size: 0.9rem;
}
/* ------- Panels (empty state) -------------------------------------------- */
.panel {
max-width: 560px;
margin: 4em auto;
padding: 2.5em 2em;
text-align: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.panel h2 {
margin: 0 0 0.4em;
font-size: 1.25rem;
color: var(--text);
}
.panel p {
margin: 0;
color: var(--text-muted);
}
/* ------- Forms ----------------------------------------------------------- */
.field {
margin-bottom: 1.1em;
}
.field > label {
display: block;
margin-bottom: 0.35em;
font-weight: 500;
font-size: 0.9rem;
color: var(--text);
}
.field input[type="text"],
.field input[type="datetime-local"],
.field input[type="file"],
.field textarea,
.field select {
width: 100%;
padding: 0.55em 0.75em;
background: var(--card);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: inherit;
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.field input[type="text"]:focus,
.field input[type="datetime-local"]:focus,
.field textarea:focus,
.field select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.field .hint {
display: block;
margin-top: 0.3em;
color: var(--text-muted);
font-size: 0.82rem;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
}
/* File input tweak: browsers style these very differently; give it some room */
.field input[type="file"] {
padding: 0.4em 0.6em;
cursor: pointer;
}
/* EasyMDE overrides so it fits our neutral theme */
.EasyMDEContainer .CodeMirror {
border-color: var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
background: var(--card);
}
.EasyMDEContainer .editor-toolbar {
border-color: var(--border-strong);
background: var(--bg);
border-top-left-radius: var(--radius-sm);
border-top-right-radius: var(--radius-sm);
}
.EasyMDEContainer .editor-preview,
.EasyMDEContainer .editor-preview-side {
background: var(--card);
color: var(--text);
}
/* Image preview */
#file-ip-1-preview {
display: block;
max-width: 100%;
max-height: 260px;
border-radius: var(--radius-sm);
margin-top: 0.4em;
object-fit: contain;
}
#file-ip-1-preview:not([src]) { display: none; }
/* ------- Buttons --------------------------------------------------------- */
.btn {
display: inline-block;
padding: 0.55em 1.1em;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: var(--card);
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn:hover { background: var(--bg); }
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn-primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--card);
color: var(--text);
border-color: var(--border-strong);
}
.btn-secondary:hover {
background: var(--bg);
border-color: var(--text-muted);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border-color: transparent;
padding: 0.35em 0.7em;
}
.btn-ghost:hover {
background: var(--bg);
color: var(--text);
}
.formActions {
display: flex;
gap: 0.6em;
align-items: center;
margin-top: 1em;
}
/* ------- Tables (DataTables-friendly) ------------------------------------ */
table.dataTable {
width: 100% !important;
border-collapse: collapse;
font-size: 0.92rem;
}
table.dataTable thead th {
background: var(--bg);
color: var(--text-muted);
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.02em;
border-bottom: 1px solid var(--border) !important;
padding: 0.7em 0.8em !important;
text-align: left;
}
table.dataTable tbody td {
padding: 0.7em 0.8em !important;
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: top;
white-space: pre-wrap;
word-wrap: break-word;
}
table.dataTable tbody tr:hover {
background: var(--bg);
}
table.dataTable tbody tr:last-child td {
border-bottom: none;
}
table.dataTable.no-footer { border-bottom: none; }
.dataTables_wrapper .dataTables_filter input {
padding: 0.35em 0.6em;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: inherit;
}
.dataTables_wrapper .dataTables_length select {
padding: 0.3em 0.5em;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: inherit;
}
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_paginate {
color: var(--text-muted);
font-size: 0.85rem;
padding-top: 0.75em;
}
.dataTables_wrapper .paginate_button {
padding: 0.35em 0.7em !important;
margin-left: 0.15em;
border-radius: var(--radius-sm);
color: var(--text-muted) !important;
border: 1px solid transparent !important;
}
.dataTables_wrapper .paginate_button.current,
.dataTables_wrapper .paginate_button:hover {
background: var(--accent-soft) !important;
color: var(--accent) !important;
border-color: var(--accent-soft) !important;
}
/* ------- Job-detail summary grid ----------------------------------------- */
.summary-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5em 1.5em;
padding: 1.1em 1.25em;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin: 0 0 1.5em;
}
.summary-grid dt {
font-weight: 500;
color: var(--text-muted);
font-size: 0.88rem;
}
.summary-grid dd {
margin: 0;
color: var(--text);
font-size: 0.92rem;
}
.section-title {
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
margin: 1.5em 0 0.5em;
}
.message-preview {
padding: 1em 1.25em;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.message-preview img { max-width: 100%; height: auto; }
.message-preview p:first-child { margin-top: 0; }
.message-preview p:last-child { margin-bottom: 0; }
/* ------- Pills ----------------------------------------------------------- */
.pill {
display: inline-block;
padding: 0.15em 0.7em;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.pill.ok { background: var(--success-bg); color: var(--success); }
.pill.err { background: var(--danger-bg); color: var(--danger); }
.pill.pending { background: var(--warning-bg); color: var(--warning); }
/* ------- Modal ----------------------------------------------------------- */
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(3px);
z-index: 20;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 21;
width: min(440px, 92vw);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5em 1.75em;
display: flex;
flex-direction: column;
gap: 0.6em;
}
.modal h3 { margin: 0; }
.modal p { color: var(--text-muted); margin: 0; }
.modal__close {
align-self: flex-end;
background: transparent;
border: none;
font-size: 1.2rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.2em 0.4em;
border-radius: 999px;
}
.modal__close:hover { background: var(--bg); color: var(--text); }
.modal__actions {
display: flex;
gap: 0.6em;
justify-content: flex-end;
margin-top: 0.6em;
}
/* ------- Responsive ------------------------------------------------------ */
@media (max-width: 640px) {
.field-row { grid-template-columns: 1fr; }
.appHeader__inner { flex-wrap: wrap; }
.appHeader__nav { margin-left: 0; }
.container { padding: 0 1em; }
.card { padding: 1.1em; }
}

View file

@ -1,144 +1,48 @@
<html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CollabCentral</title>
<link id="appName" rel="icon" type="image/x-icon" style="border-radius: 50%;" href="favicon.ico">
<style>
* { box-sizing: border-box; }
body {
background-color: #FFFFFF;
color: #2F3941;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 10px;
}
.header {
display: block;
background-color: tan;
width: 90%;
height: auto;
text-align: center;
margin: 0 auto;
}
#appInfo {
display: flex;
text-align: center;
align-content: center;
}
.name {
display: block;
text-align: right;
}
.container {
max-width: 1100px;
margin: 1em auto;
padding: 0 1em;
}
h2.jobTitle {
margin: 0.5em 0 0.2em;
}
.subtitle {
color: #6c757d;
margin: 0 0 1em;
}
.summary-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.45em 1.5em;
margin: 1em 0 1.5em;
padding: 1em 1.2em;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.summary-grid dt {
font-weight: 600;
color: #555;
}
.summary-grid dd { margin: 0; }
.section-title {
font-size: 1.05em;
font-weight: 700;
margin: 1.5em 0 0.4em;
color: #2F3941;
}
.message-preview {
border: 1px solid #ddd;
border-radius: 6px;
padding: 1em 1.2em;
background: #fafafa;
margin: 0.4em 0 1em;
}
.message-preview img { max-width: 100%; height: auto; }
.pill {
display: inline-block;
padding: 0.15em 0.7em;
border-radius: 999px;
font-size: 0.85em;
font-weight: 600;
}
.pill.ok { background: #d4edda; color: #155724; }
.pill.err { background: #f8d7da; color: #721c24; }
.pill.pending { background: #fff3cd; color: #856404; }
td { white-space: pre-wrap !important; word-wrap: break-word; }
.hidden { display: none; }
.panel-empty {
padding: 2em;
max-width: 600px;
margin: 2em auto;
text-align: center;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
<link id="appFavicon" rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="css/app.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.css">
<script src="https://code.jquery.com/jquery-1.11.2.min.js"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.css" />
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.js"></script>
</head>
<body>
<div class="header">
<div id="appInfo">
<img id="appIcon" style="border-radius: 50%; padding: 10pt;">
<div id="appLabel" style="font-size: 40pt;margin: auto;"></div>
</div>
<div style="text-align: center;"><a id="jobLink" href=''>Back to Jobs</a></div>
<div id="name" style="text-align: right;"></div>
</div>
<body data-page="monitor" data-title-suffix="Job Detail">
<div id="unauthorizedPanel" class="panel-empty hidden">
<header class="appHeader">
<div class="appHeader__inner">
<div class="appHeader__brand">
<div id="appAvatar" class="appHeader__avatar"></div>
<div id="appLabel" class="appHeader__label">Loading…</div>
</div>
<nav class="appHeader__nav">
<a href="./sendMessage.html" data-nav="send">Send Message</a>
<a href="./monitorJobs.html" data-nav="monitor">Monitor Jobs</a>
</nav>
<div class="appHeader__spacer"></div>
<div id="appUser" class="appHeader__user"></div>
</div>
</header>
<div id="unauthorizedPanel" class="panel hidden">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list. Contact the bot's administrator.</p>
<p>Your account isn't on the authorized list for this bot. Contact the bot's administrator.</p>
</div>
<div id="notFoundPanel" class="panel-empty hidden">
<div id="notFoundPanel" class="panel hidden">
<h2>Job not found</h2>
<p>The job you're looking for doesn't exist, has been purged, or belongs to a different bot.</p>
</div>
<div id="detailPanel" class="container hidden">
<h2 class="jobTitle" id="jobTitle"></h2>
<p class="subtitle" id="subtitle"></p>
<div class="card">
<h1 class="pageTitle" id="jobTitle"></h1>
<p class="pageSubtitle" id="subtitle"></p>
<dl class="summary-grid" id="summaryGrid"></dl>
@ -146,19 +50,21 @@
<div class="message-preview" id="messagePreview"></div>
<div class="section-title">Recipients (<span id="recipientCount">0</span>)</div>
<table id="recipients" style="width: 100%;">
<table id="recipients" class="dataTable" style="width: 100%;">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Send Time</th>
<th>Send time</th>
<th>Error</th>
</tr>
</thead>
</table>
</div>
</div>
<script src="js/app.js"></script>
<script src="jobDetail.js"></script>
</body>

View file

@ -1,97 +1,74 @@
var pathArray = window.location.pathname.split('/');
var appName = pathArray[2];
// Page script for jobDetail.html. Shared bootstrapping lives in js/app.js;
// this file just fetches the requested job and renders the detail view.
var urlParams = new URLSearchParams(window.location.search);
var jobId = urlParams.get('jobId');
if (!getCookie("id")) {
var randomNumber = Math.random().toString().substring(2);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(res => res.text())
.then(res => { window.location.href = res + randomNumber; });
} else {
bootstrap();
}
CollabCentral.init(function (ctx) {
loadJobDetail(ctx.appName);
});
function bootstrap() {
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label + " — Job #" + (jobId || "?");
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/monitorJobs.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
loadJobDetail();
})
.catch(err => console.error("Failed to load /info:", err));
}
function loadJobDetail() {
function loadJobDetail(appName) {
if (!jobId) {
document.getElementById("notFoundPanel").classList.remove("hidden");
document.getElementById('notFoundPanel').classList.remove('hidden');
return;
}
fetch('/CollabCentral/' + appName + '/jobs/detail/' + encodeURIComponent(jobId))
.then(res => {
.then(function (res) {
if (res.status === 404) {
document.getElementById("notFoundPanel").classList.remove("hidden");
document.getElementById('notFoundPanel').classList.remove('hidden');
return null;
}
if (!res.ok) throw new Error("HTTP " + res.status);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(job => {
.then(function (job) {
if (!job) return;
renderJob(job);
document.getElementById("detailPanel").classList.remove("hidden");
document.getElementById('detailPanel').classList.remove('hidden');
})
.catch(err => {
console.error("Failed to load job detail:", err);
document.getElementById("notFoundPanel").classList.remove("hidden");
.catch(function (err) {
console.error('Failed to load job detail:', err);
document.getElementById('notFoundPanel').classList.remove('hidden');
});
}
function renderJob(job) {
document.getElementById("jobTitle").textContent = "Job #" + (job.jobId || "?");
var subtitle = "Sent by " + (job.senderDisplayName || "unknown");
if (job.startTime) subtitle += " · started " + formatDate(job.startTime);
document.getElementById("subtitle").textContent = subtitle;
document.getElementById('jobTitle').textContent = 'Job #' + (job.jobId || '?');
var subtitle = 'Sent by ' + (job.senderDisplayName || 'unknown');
if (job.startTime) subtitle += ' · started ' + CollabCentral.formatDate(job.startTime);
document.getElementById('subtitle').textContent = subtitle;
var dl = document.getElementById("summaryGrid");
dl.innerHTML = "";
var stats = job.stats || {};
var rows = [
["Job ID", job.jobId],
["Submitted by", job.senderDisplayName],
["Scheduled for", job.scheduledFor ? formatDate(job.scheduledFor) : "Sent immediately"],
["Started", formatDate(job.startTime)],
["Completed", job.endTime ? formatDate(job.endTime) : "In progress"],
["Total duration", stats.totalTime],
["Webex time", stats.webexTime],
["Average per message", stats.averageTime],
["Recipients", (job.memberList || []).length],
["Delivered", formatSuccess(stats)]
['Job ID', job.jobId],
['Submitted by', job.senderDisplayName],
['Scheduled for', job.scheduledFor ? CollabCentral.formatDate(job.scheduledFor) : 'Sent immediately'],
['Started', CollabCentral.formatDate(job.startTime)],
['Completed', job.endTime ? CollabCentral.formatDate(job.endTime) : 'In progress'],
['Total duration', stats.totalTime],
['Webex time', stats.webexTime],
['Average per message', stats.averageTime],
['Recipients', (job.memberList || []).length],
['Delivered', formatSuccess(stats)],
];
var dl = document.getElementById('summaryGrid');
dl.innerHTML = '';
for (var i = 0; i < rows.length; i++) {
var key = rows[i][0], val = rows[i][1];
if (val === undefined || val === null || val === "") continue;
var dt = document.createElement("dt"); dt.textContent = key;
var dd = document.createElement("dd"); dd.textContent = val;
dl.appendChild(dt); dl.appendChild(dd);
if (val === undefined || val === null || val === '') continue;
var dt = document.createElement('dt');
dt.textContent = key;
var dd = document.createElement('dd');
dd.textContent = val;
dl.appendChild(dt);
dl.appendChild(dd);
}
var preview = document.getElementById("messagePreview");
// Message preview: prefer HTML, fall back to markdown/text.
var preview = document.getElementById('messagePreview');
var msg = (job.message && job.message.english) || job.message || {};
if (msg.html) {
preview.innerHTML = msg.html;
@ -99,14 +76,14 @@ function renderJob(job) {
preview.textContent = msg.markdown;
} else if (msg.text) {
preview.textContent = msg.text;
} else if (typeof msg.raw === "string") {
} else if (typeof msg.raw === 'string') {
preview.textContent = msg.raw;
} else {
preview.innerHTML = "<em>No message content available.</em>";
preview.innerHTML = '<em>No message content available.</em>';
}
var members = job.memberList || [];
document.getElementById("recipientCount").textContent = members.length;
document.getElementById('recipientCount').textContent = members.length;
$('#recipients').dataTable({
data: members.map(formatRecipientRow),
@ -115,22 +92,22 @@ function renderJob(job) {
{ data: 'email' },
{ data: 'status' },
{ data: 'time' },
{ data: 'error' }
{ data: 'error' },
],
pageLength: 50,
lengthMenu: [25, 50, 100, 250],
order: [[2, 'asc']],
searching: true,
info: true,
paging: true
paging: true,
});
}
function formatRecipientRow(m) {
var result = m.results || null;
var status;
var email = "—";
var err = "";
var email = '—';
var err = '';
if (!result) {
status = '<span class="pill pending">Pending</span>';
@ -142,38 +119,25 @@ function formatRecipientRow(m) {
email = result.toPersonEmail || email;
if (result.message) err = result.message;
else if (result.errors && result.errors.length) {
err = result.errors.map(function (e) { return e.description || e.code || JSON.stringify(e); }).join("; ");
err = result.errors.map(function (e) {
return e.description || e.code || JSON.stringify(e);
}).join('; ');
}
}
return {
displayName: m.displayName || "—",
displayName: m.displayName || '—',
email: email,
status: status,
time: m.msgTime ? (m.msgTime / 1000).toFixed(2) + " s" : "—",
error: err
time: m.msgTime ? (m.msgTime / 1000).toFixed(2) + ' s' : '—',
error: err,
};
}
function formatSuccess(stats) {
if (!stats || stats.succeededMsgs === undefined) return null;
var pct = (typeof stats.succeededMsgPct === "number") ? stats.succeededMsgPct.toFixed(1) : stats.succeededMsgPct;
return stats.succeededMsgs + " / " + stats.totalMsgs + " (" + pct + "%)";
}
function formatDate(d) {
if (!d) return null;
try { return new Date(d).toLocaleString(); } catch (e) { return d; }
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1);
if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
}
return "";
var pct = (typeof stats.succeededMsgPct === 'number')
? stats.succeededMsgPct.toFixed(1)
: stats.succeededMsgPct;
return stats.succeededMsgs + ' / ' + stats.totalMsgs + ' (' + pct + '%)';
}

159
html/js/app.js Normal file
View file

@ -0,0 +1,159 @@
// 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,
};
})();

View file

@ -1,94 +1,60 @@
<html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CollabCentral</title>
<style>
* {
box-sizing: border-box;
}
body {
background-color: #FFFFFF;
color: #2F3941;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 10px;
}
#table {
width: 80%;
}
td {
white-space: pre-wrap !important;
word-wrap:break-word;
}
.header {
display: block;
background-color: tan;
width: 80%;
height: auto;
text-align: center;
}
.name {
display: block;
text-align: right;
align-content: right;
}
#appInfo {
display: flex;
text-align: center;
align-content: center;
}
.hidden {
display: none;
}
</style>
<link id="appFavicon" rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="css/app.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.css">
<script src="https://code.jquery.com/jquery-1.11.2.min.js"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.css" />
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.js"></script>
</head>
<body>
<link id="appName" rel="icon" type="image/x-icon" style="border-radius: 50%;" href="favicon.ico">
<body data-page="monitor" data-title-suffix="Monitor Jobs">
<div class="header">
<div id="appInfo">
<img id="appIcon" style="border-radius: 50%; padding: 10pt;">
<div id="appLabel" style="font-size: 40pt;margin: auto;"></div>
<header class="appHeader">
<div class="appHeader__inner">
<div class="appHeader__brand">
<div id="appAvatar" class="appHeader__avatar"></div>
<div id="appLabel" class="appHeader__label">Loading…</div>
</div>
<div style="text-align: center;"><a id="jobLink" href=''>Send Message</a></div>
<div id="name" style="text-align: right;"></div>
<nav class="appHeader__nav">
<a href="./sendMessage.html" data-nav="send">Send Message</a>
<a href="./monitorJobs.html" data-nav="monitor">Monitor Jobs</a>
</nav>
<div class="appHeader__spacer"></div>
<div id="appUser" class="appHeader__user"></div>
</div>
</header>
<div id="unauthorizedPanel" class="hidden" style="padding: 2em; max-width: 600px; margin: 2em auto; text-align: center; border: 1px solid #ddd; border-radius: 8px;">
<div id="unauthorizedPanel" class="panel hidden">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list for this bot. If you believe this is a mistake, contact the bot's administrator.</p>
</div>
<div id="jobsPanel" class="hidden">
<h3>Running Jobs</h3>
<div class="table"></div>
<table id="runningJobs" class="table">
<div id="jobsPanel" class="container hidden">
<div class="card">
<h2 class="pageTitle" style="font-size: 1.05rem;">Running</h2>
<p class="pageSubtitle">Jobs currently sending.</p>
<table id="runningJobs" class="dataTable">
<thead>
<tr>
<th>Submitter</th>
<th>App</th>
<th>Start Time</th>
<th>Total Recipients</th>
<th>Completed Recipients</th>
<th>Start time</th>
<th>Total recipients</th>
<th>Completed</th>
</tr>
</thead>
</table>
<h3>Scheduled Jobs</h3>
<table id="scheduledJobs">
</div>
<div class="card">
<h2 class="pageTitle" style="font-size: 1.05rem;">Scheduled</h2>
<p class="pageSubtitle">Jobs waiting to fire.</p>
<table id="scheduledJobs" class="dataTable">
<thead>
<tr>
<th>Submitter</th>
@ -99,27 +65,29 @@
</tr>
</thead>
</table>
</div>
<h3>Completed Jobs</h3>
<table id="completedJobs">
<div class="card">
<h2 class="pageTitle" style="font-size: 1.05rem;">Completed</h2>
<p class="pageSubtitle">Jobs retained for 30 days. Click a job ID to open its detail view.</p>
<table id="completedJobs" class="dataTable">
<thead>
<tr>
<th>jobId</th>
<th>Job</th>
<th>Submitter</th>
<th>App</th>
<th>Message</th>
<th>Start Time</th>
<th>End Time</th>
<th>Total Time</th>
<th>Webex Time</th>
<th>Average Message Time</th>
<th>Messages Delivered</th>
<th>Total Messages</th>
<th>Success Rate</th>
<th>Started</th>
<th>Completed</th>
<th>Total time</th>
<th>Delivered</th>
<th>Success rate</th>
</tr>
</thead>
</table>
</div>
</div>
<script src="js/app.js"></script>
<script src="monitorJobs.js"></script>
</body>

View file

@ -1,132 +1,126 @@
var pathArray = window.location.pathname.split('/');
var appName = pathArray[2];
// Page script for monitorJobs.html. Shared bootstrapping lives in js/app.js;
// this file just wires the three DataTables (running, scheduled, completed)
// once the user is confirmed authorized.
if (getCookie("id")) {
console.log("Found ID Cookie: " + getCookie("id"));
// Fetch bot metadata + authorization state, then render the page.
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label;
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/sendMessage.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
document.getElementById("jobsPanel").classList.remove("hidden");
initJobTables();
})
.catch(err => console.error("Failed to load /info:", err));
} else {
console.log("No ID Cookie found.");
var randomNumber = Math.random().toString();
randomNumber = randomNumber.substring(2, randomNumber.length);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(res => res.text())
.then((res) => {
window.location.href = res + randomNumber;
CollabCentral.init(function (ctx) {
document.getElementById('jobsPanel').classList.remove('hidden');
initJobTables(ctx.appName);
});
function jobDetailLink(appName, jobId, label) {
if (jobId === undefined || jobId === null) return CollabCentral.escapeHtml(label);
return '<a href="./jobDetail.html?jobId=' + encodeURIComponent(jobId) + '">' +
CollabCentral.escapeHtml(label) + '</a>';
}
function initJobTables() {
function renderDate(val) {
return val ? CollabCentral.escapeHtml(CollabCentral.formatDate(val)) : '';
}
function renderSuccessRate(pct) {
if (pct === undefined || pct === null || pct === '') return '';
var n = typeof pct === 'number' ? pct : parseFloat(pct);
if (isNaN(n)) return CollabCentral.escapeHtml(pct);
return n.toFixed(1) + '%';
}
function renderDelivered(row) {
var s = row.stats || {};
if (s.succeededMsgs === undefined || s.totalMsgs === undefined) return '';
return s.succeededMsgs + ' / ' + s.totalMsgs;
}
function initJobTables(appName) {
$('#runningJobs').dataTable({
ajax: {
url: '/CollabCentral/' + appName + '/jobs/list/running',
dataSrc: ''
dataSrc: '',
},
columns: [
{ data: 'senderDisplayName' },
{ data: 'appName' },
{ data: 'startTime' },
{ data: 'startTime', render: function (v) { return renderDate(v); } },
{ data: 'totalRecipients' },
{
data: 'completedRecipients',
render: function (val, type, row) {
if (type !== 'display' || row.jobId === undefined || row.jobId === null) return val;
return val + ' <a href="/CollabCentral/' + appName + '/jobDetail.html?jobId=' + encodeURIComponent(row.jobId) + '" style="margin-left: 0.5em; font-size: 0.85em;">view</a>';
}
}
var text = (val === undefined || val === null) ? 'view' : val + ' · view';
return jobDetailLink(appName, row.jobId, text);
},
},
],
searching: false,
info: false,
ordering: false,
paging: false
paging: false,
language: { emptyTable: 'No running jobs.' },
});
$('#scheduledJobs').dataTable({
ajax: {
url: '/CollabCentral/' + appName + '/jobs/list/scheduled',
dataSrc: ''
dataSrc: '',
},
columns: [
{ data: 'senderDisplayName' },
{ data: 'appName' },
{ data: 'message' },
{
data: 'message',
render: function (val, type, row) {
if (type !== 'display') return '';
// Message is stored per-language; prefer english.html or english.markdown.
var m = (val && (val.english || val)) || {};
var html = m.html || m.markdown || m.text || '';
// Truncate to keep the row compact
var text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (text.length > 120) text = text.slice(0, 117) + '…';
return CollabCentral.escapeHtml(text);
},
},
{ data: 'totalRecipients' },
{ data: 'scheduledFor' }
{ data: 'scheduledFor', render: function (v) { return renderDate(v); } },
],
searching: false,
info: false,
ordering: false,
paging: false
paging: false,
language: { emptyTable: 'No scheduled jobs.' },
});
$('#completedJobs').dataTable({
ajax: {
url: '/CollabCentral/' + appName + '/jobs/list/completed',
dataSrc: ''
dataSrc: '',
},
columns: [
{
data: 'jobId',
render: function (jobId) {
if (jobId === undefined || jobId === null) return '';
return '<a href="/CollabCentral/' + appName + '/jobDetail.html?jobId=' + encodeURIComponent(jobId) + '">#' + jobId + '</a>';
}
render: function (jobId, type) {
if (type !== 'display') return jobId;
return jobDetailLink(appName, jobId, '#' + jobId);
},
},
{ data: 'senderDisplayName' },
{ data: 'appName' },
{ data: 'message.english.html' },
{ data: 'startTime' },
{ data: 'endTime' },
{ data: 'startTime', render: function (v) { return renderDate(v); } },
{ data: 'endTime', render: function (v) { return renderDate(v); } },
{ data: 'stats.totalTime' },
{ data: 'stats.webexTime' },
{ data: 'stats.averageTime' },
{ data: 'stats.succeededMsgs' },
{ data: 'stats.totalMsgs' },
{ data: 'stats.succeededMsgPct' }
{
data: null,
render: function (row, type) {
if (type !== 'display') return '';
return renderDelivered(row);
},
},
{ data: 'stats.succeededMsgPct', render: function (v) { return renderSuccessRate(v); } },
],
searching: false,
info: false,
ordering: false,
paging: false
order: [[3, 'desc']],
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
searching: true,
info: true,
paging: true,
language: { emptyTable: 'No completed jobs in the last 30 days.' },
});
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}

View file

@ -1,308 +1,100 @@
<html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CollabCentral</title>
<link id="appName" rel="icon" type="image/x-icon" style="border-radius: 50%;" href="favicon.ico">
<script>
let cookie = document.cookie;
if (cookie) {
} else {
window.location.href = "index.html";
}
</script>
<style>
* {
box-sizing: border-box;
}
input {
width: 60%;
padding: 12px 20px;
margin: 8px 0;
margin-bottom: 10px;
box-sizing: border-box;
display: inline-block;
border-radius: 4px;
}
.form-field input {
border: 1px solid #87929D;
border-radius: 4px;
padding: 10px;
width: 75%;
}
.form-field label {
display: block;
font-size: 13px;
}
label {
margin-top: 10px;
display: inline-block;
}
body {
background-color: #FFFFFF;
color: #2F3941;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 10px;
}
.header {
display: block;
background-color: tan;
width: 75%;
height: auto;
}
form {
display: block;
margin-top: 0em;
}
#file-ip-1-preview {
width: auto;
height: auto;
padding: 10px;
}
.groups {
width: 1000px;
height: auto;
}
#my-text-area {
height: 250px;
width: 75%;
}
.EasyMDEContainer {
display: block;
width: 75%;
border: 1px solid #87929D;
border-radius: 4px;
}
.submit {
width: 75%;
height: auto;
margin: 10px;
}
.modal {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.4rem;
width: 450px;
padding: 1.3rem;
min-height: 250px;
position: absolute;
top: 20%;
background-color: white;
border: 1px solid #ddd;
border-radius: 15px;
z-index: 2;
}
.modal .flex {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal input {
padding: 0.7rem 1rem;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 0.9em;
}
.modal p {
font-size: 0.9rem;
color: #777;
margin: 0.4rem 0 0.2rem;
}
.submit button {
display: inline-block;
padding: 0.8rem 1.4rem;
font-weight: 700;
background-color: black;
color: white;
border-radius: 5px;
text-align: center;
font-size: 1em;
}
.btn {
display: inline-block;
padding: 0.8rem 1.4rem;
font-weight: 700;
background-color: black;
color: white;
border-radius: 5px;
text-align: center;
font-size: 1em;
}
.btn-open {
position: absolute;
bottom: 150px;
}
.btn-close {
transform: translate(10px, -20px);
padding: 0.5rem 0.7rem;
background: #eee;
border-radius: 50%;
}
.overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
z-index: 1;
}
.hidden {
display: none;
}
.name {
display: block;
text-align: right;
align-content: right;
}
#appInfo {
display: flex;
text-align: center;
align-content: center;
}
#multi_option {
max-width: 100%;
width: 350px;
}
vscomp-toggle-button {
padding: 10px 30px 10px 10px;
border-radius: 5px;
}
</style>
<link id="appFavicon" rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="css/app.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<link rel="stylesheet" href="js/virtual-select.min.css" />
<link rel="stylesheet" href="js/virtual-select.min.css">
<script src="js/virtual-select.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
</head>
<body>
<body data-page="send" data-title-suffix="Send Message">
<div class="header">
<div id="appInfo">
<img id="appIcon" style="border-radius: 50%; padding: 10pt;">
<div id="appLabel" style="font-size: 40pt;margin: auto;"></div>
<header class="appHeader">
<div class="appHeader__inner">
<div class="appHeader__brand">
<div id="appAvatar" class="appHeader__avatar"></div>
<div id="appLabel" class="appHeader__label">Loading…</div>
</div>
<div style="text-align: center;"><a id="jobLink" href=''>Monitor Jobs</a></div>
<div id="name" style="text-align: right;"></div>
<nav class="appHeader__nav">
<a href="./sendMessage.html" data-nav="send">Send Message</a>
<a href="./monitorJobs.html" data-nav="monitor">Monitor Jobs</a>
</nav>
<div class="appHeader__spacer"></div>
<div id="appUser" class="appHeader__user"></div>
</div>
</header>
<div id="unauthorizedPanel" class="hidden" style="padding: 2em; max-width: 600px; margin: 2em auto; text-align: center; border: 1px solid #ddd; border-radius: 8px;">
<div id="unauthorizedPanel" class="panel hidden">
<h2>You don't have access to this bot</h2>
<p>Your account isn't on the authorized list for this bot. If you believe this is a mistake, contact the bot's administrator.</p>
</div>
<div id="formPanel" class="form-input hidden">
<form action="" id="sendMessage" enctype="multipart/form-data">
<div class="form-field">
<div id="formPanel" class="container hidden">
<div class="card">
<h1 class="pageTitle">Compose a message</h1>
<p class="pageSubtitle">Pick your recipients, write the message, and send now or schedule it for later.</p>
<img id="file-ip-1-preview">
<label for="uploadImage">Upload Image</label>
<input type="file" id="uploadImage" accept="image/*" name="uploadImage"
onchange="showImagePreview(event);">
</div>
<form id="sendMessage" enctype="multipart/form-data">
<div class="field">
<label for="my-text-area">Message</label>
<textarea id="my-text-area"></textarea>
<div class="form-field">
<div class="groups">
<label for="favGroups">Favorite Groups:</label>
<select id="favGroups" multiple name="favGroups" placeholder="Favorite Groups"
data-silent-initial-value-set="false">
</select>
</div>
<div class="groups">
<label>New Groups:</label>
<select id="newGroups" multiple name="newGroups" placeholder="New Groups"
data-silent-initial-value-set="false">
</select>
</div>
<div class="people">
<label for="uploadCSV">Upload CSV:</label>
<div class="field">
<label for="uploadImage">Attach image (optional)</label>
<input type="file" id="uploadImage" accept="image/*" name="uploadImage" onchange="showImagePreview(event);">
<img id="file-ip-1-preview" alt="">
</div>
<div class="field">
<label for="favGroups">Favorite groups</label>
<select id="favGroups" multiple name="favGroups" placeholder="Favorite groups"
data-silent-initial-value-set="false"></select>
</div>
<div class="field">
<label for="newGroups">Additional groups</label>
<select id="newGroups" multiple name="newGroups" placeholder="Search all groups"
data-silent-initial-value-set="false"></select>
</div>
<div class="field">
<label for="uploadCSV">Upload CSV of recipients (optional)</label>
<input type="file" id="uploadCSV" name="uploadCSV" accept="text/csv">
</div>
</div>
<div class="form-field">
<div class="when">
<label for="meeting-time">Schedule for:</label>
<input type="datetime-local" id="scheduledFor" name="scheduledFor" />
</div>
</div>
<div class="submit">
<button id="submit" type="submit">Submit</button>
<span class="hint">CSV of Webex person IDs or emails to add to the recipient list.</span>
</div>
<div class="field">
<label for="scheduledFor">Schedule for (leave blank to send immediately)</label>
<input type="datetime-local" id="scheduledFor" name="scheduledFor">
</div>
<div class="formActions">
<button id="submit" type="submit" class="btn btn-primary">Review &amp; send</button>
</div>
</form>
<section class="modal hidden">
<div class="flex">
<button class="btn-close" onclick="continueEditing()">X</button>
</div>
<div>
</div>
<section class="modal hidden" aria-modal="true" role="dialog">
<button class="modal__close" onclick="continueEditing()" aria-label="Close">×</button>
<h3>Ready to send?</h3>
<p id="modalMessage">
This message will be sent to XX people.<br>
Ready to send?
</p>
<p id="modalMessage">This message will be sent.</p>
<div class="modal__actions">
<button class="btn btn-secondary" onclick="continueEditing()">Keep editing</button>
<button class="btn btn-primary" onclick="submit()">Send</button>
</div>
<button class="btn" onclick="continueEditing()">Continue Editing</button>
<button class="btn" onclick="submit()">Submit</button>
</section>
<div class="overlay hidden"></div>
</div>
<script src="js/app.js"></script>
<script src="sendMessage.js"></script>
</body>
</html>

View file

@ -1,43 +1,53 @@
const listbox = document.querySelector('#list');
const modal = document.querySelector(".modal");
const overlay = document.querySelector(".overlay");
const openModalBtn = document.querySelector(".btn-open");
const closeModalBtn = document.querySelector(".btn-close");
// Page script for sendMessage.html. Bootstrapping (auth cookie check, /info
// fetch, header rendering, active-nav highlight) all live in js/app.js;
// this file just wires the compose form.
var pathArray = window.location.pathname.split('/'); //Gets the path of the URL called
var appName = pathArray[2]; //Gets the appName from the path.
var modal = document.querySelector('.modal');
var overlay = document.querySelector('.overlay');
var message = new EasyMDE({
element: document.getElementById('my-text-area'),
toolbar: [
'bold', 'italic', '|',
'heading-1', 'heading-2', 'heading-3', '|',
'code', 'unordered-list', 'ordered-list', 'link', 'quote', '|',
'preview'
],
maxHeight: '260px',
});
CollabCentral.init(function (ctx) {
// User is authenticated and authorized. Reveal the form + populate the
// group selectors from the backend.
document.getElementById('formPanel').classList.remove('hidden');
loadFavoriteGroups(ctx.appName);
loadNewGroups(ctx.appName);
});
document.getElementById('sendMessage').onsubmit = function (event) {
event.preventDefault() // prevent form from posting without JS
var xhttp = new XMLHttpRequest(); // create new AJAX request
event.preventDefault();
var appName = CollabCentral.appName;
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == this.DONE && this.status == 200) { // sucess from server
result = JSON.parse(xhttp.response);
console.log(xhttp.responseText)
console.log(xhttp.response);
var message = "I sent you a test message in your Webex Client for review.<br><br>";
message += "Recipients: " + result.memberList.length + "<br><br>";
if (document.getElementById("scheduledFor").value) {
message += "Scheduled for " + document.getElementById("scheduledFor").value;
if (this.readyState === this.DONE && this.status === 200) {
var result = JSON.parse(xhttp.response);
var scheduled = document.getElementById('scheduledFor').value;
var text = 'This message will be sent to <strong>' + result.memberList.length + '</strong> recipient(s).<br><br>';
if (scheduled) {
text += 'Scheduled for ' + CollabCentral.escapeHtml(scheduled) + '.';
} else {
message += "Message will be sent immediately.";
text += 'It will be sent immediately once you confirm.';
}
document.getElementById("modalMessage").innerHTML = message;
document.getElementById('modalMessage').innerHTML = text;
openModal();
} else { // errors occured
}
}
};
var formData = new FormData()
formData.append('uploadImage', document.getElementById('uploadImage').files[0]) // since inputs allow multi files submission, therefore files are in array
formData.append('message', message.value())
formData.append('uploadCSV', document.getElementById('uploadCSV').files[0]) // since inputs allow multi files submission, therefore files are in array
var formData = new FormData();
formData.append('uploadImage', document.getElementById('uploadImage').files[0]);
formData.append('message', message.value());
formData.append('uploadCSV', document.getElementById('uploadCSV').files[0]);
formData.append('appName', appName);
var selectedGroups = [];
@ -47,176 +57,90 @@ document.getElementById('sendMessage').onsubmit = function (event) {
for (var group of document.querySelector('#newGroups').getSelectedOptions()) {
selectedGroups.push(group.value);
}
formData.append('groups', selectedGroups);
formData.append('scheduledFor', document.getElementById("scheduledFor").value);
xhttp.open("POST", "/CollabCentral/" + appName + "/jobs/edit")
console.log(xhttp);
xhttp.send(formData)
}
formData.append('scheduledFor', document.getElementById('scheduledFor').value);
xhttp.open('POST', '/CollabCentral/' + appName + '/jobs/edit');
xhttp.send(formData);
};
function showImagePreview(event) {
if (event.target.files.length > 0) {
if (!event.target.files || !event.target.files.length) return;
var src = URL.createObjectURL(event.target.files[0]);
var preview = document.getElementById("file-ip-1-preview");
var preview = document.getElementById('file-ip-1-preview');
preview.src = src;
preview.style.display = "block";
preview.style.maxHeight = "350px";
preview.style.maxWidth = "500px";
}
preview.style.display = 'block';
}
let id = getCookie("id");
// Fetch bot metadata + authorization state, then either render the form or
// show the "not authorized" panel. All bot-specific labels and icons come from
// /info so adding a new bot doesn't require any front-end changes.
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label;
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/monitorJobs.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
document.getElementById("formPanel").classList.remove("hidden");
loadFavoriteGroups();
loadNewGroups();
})
.catch(err => {
console.error("Failed to load /info:", err);
});
function loadFavoriteGroups() {
var x = document.getElementById("favGroups");
function loadFavoriteGroups(appName) {
var el = document.getElementById('favGroups');
fetch('/CollabCentral/' + appName + '/user/groups/list')
.then(res => res.json())
.then((res) => {
for (var group of res) {
var option = document.createElement("option");
option.text = group.name;
option.value = group.id;
x.add(option);
.then(function (res) { return res.json(); })
.then(function (groups) {
for (var i = 0; i < groups.length; i++) {
var option = document.createElement('option');
option.text = groups[i].name;
option.value = groups[i].id;
el.add(option);
}
VirtualSelect.init({ ele: '#favGroups' });
});
}
function loadNewGroups() {
var y = document.getElementById("newGroups");
function loadNewGroups(appName) {
var el = document.getElementById('newGroups');
fetch('/CollabCentral/' + appName + '/user/groups/find')
.then(res => res.json())
.then((res) => {
for (var group of res) {
var option = document.createElement("option");
option.text = group.displayName;
option.value = group.id;
y.add(option);
.then(function (res) { return res.json(); })
.then(function (groups) {
for (var i = 0; i < groups.length; i++) {
var option = document.createElement('option');
option.text = groups[i].displayName;
option.value = groups[i].id;
el.add(option);
}
VirtualSelect.init({ ele: '#newGroups' });
});
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
const message = new EasyMDE({
element: document.getElementById('my-text-area'),
toolbar: ["bold", "italic", "|", "heading-1", "heading-2", "heading-3", "|", "code", "unordered-list", "ordered-list", "link", "quote", "|", "preview"],
maxHeight: "200px"
});
function submit() {
if (document.getElementById("scheduledFor").value) {
fetch("/CollabCentral/" + appName + "/jobs/schedule", {
method: "POST",
redirect: "follow"
})
.then(response => {
var appName = CollabCentral.appName;
var scheduled = document.getElementById('scheduledFor').value;
var url = scheduled
? '/CollabCentral/' + appName + '/jobs/schedule'
: '/CollabCentral/' + appName + '/jobs/runNow';
fetch(url, { method: 'POST', redirect: 'follow' })
.then(function (response) {
clearForm();
// HTTP 301 response
if (response.redirected) {
window.location.href = response.url;
}
if (response.redirected) window.location.href = response.url;
})
.catch(function (err) {
console.info(err + " url: " + url);
console.error('Submit failed:', err);
});
} else {
console.log("RunNow selected.");
fetch("/CollabCentral/" + appName + "/jobs/runNow", {
method: "POST",
redirect: "follow"
})
.then(response => {
clearForm();
// HTTP 301 response
if (response.redirected) {
window.location.href = response.url;
}
})
.catch(function(err) {
console.info(err);
});
}
closeModal();
}
function continueEditing() {
closeModal();
function continueEditing() { closeModal(); }
function openModal() {
modal.classList.remove('hidden');
overlay.classList.remove('hidden');
}
const openModal = function () {
modal.classList.remove("hidden");
overlay.classList.remove("hidden");
};
function closeModal() {
modal.classList.add('hidden');
overlay.classList.add('hidden');
}
const closeModal = function () {
modal.classList.add("hidden");
overlay.classList.add("hidden");
};
overlay.addEventListener("click", closeModal);
overlay.addEventListener('click', closeModal);
function clearForm() {
document.getElementById('uploadImage').innerHTML = "";
message.value("");
document.getElementById('uploadCSV').innerHTML = "";
document.getElementById('favGroups').selected = null;
document.getElementById('newGroups').selected = null;
document.getElementById('scheduledFor').innerHTML = "";
document.getElementById('uploadImage').value = '';
message.value('');
document.getElementById('uploadCSV').value = '';
document.getElementById('scheduledFor').value = '';
var preview = document.getElementById('file-ip-1-preview');
preview.removeAttribute('src');
preview.style.display = 'none';
}