Brings the ISO-packaged portal copy current with the live cezen-portal/ working directory, which had drifted across the entire V1.0 GA polish initiative (Phases 1-10) plus this session's topnav icon-order bugfix. - 52 existing files updated, 2 new pages added (about.html, health.html) - cgit/cezen-portal/ is now byte-identical to cezen-portal/ - Backend files (rag_ingest.py, cezen_license.py) already in sync; main.py intentionally left as-is (cgit is ahead there with workstation-category commercial-fit logic not yet backported to the live working copy) Next: rebuild the ISO (bash autoinstall/build-iso.sh) to bake this in.
790 lines
37 KiB
JavaScript
790 lines
37 KiB
JavaScript
/**
|
||
* branding.js — Nexus One AI White-Label Branding + Tier Feature Gating
|
||
* Fetches /api/settings/branding (public, no auth) and:
|
||
* 1. Applies org name, logo, accent color, and page title across all pages.
|
||
* 2. Locks nav items that require a higher tier, showing an upgrade prompt.
|
||
* Inject BEFORE </body> on every portal page.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ── Tier hierarchy ──────────────────────────────────────────────────────────
|
||
var TIER_RANK = { starter: 0, basic: 1, pro: 2, max: 3 };
|
||
|
||
// ── Feature → minimum tier required ────────────────────────────────────────
|
||
// Keyed by partial href match (filename). Value = minimum slug to access.
|
||
var FEATURE_TIERS = {
|
||
// Basic-only features (not available on Starter)
|
||
'analytics.html': 'basic',
|
||
'api-keys.html': 'basic',
|
||
'benchmark.html': 'basic',
|
||
'chat-multi.html': 'basic',
|
||
'feedback.html': 'basic',
|
||
'notifications.html': 'basic',
|
||
'prompt-studio.html': 'basic',
|
||
'model-compare.html': 'basic',
|
||
|
||
// Pro-only features
|
||
'agents.html': 'pro',
|
||
'api-playground.html':'pro',
|
||
'chatrooms.html': 'pro',
|
||
'connectors.html': 'pro',
|
||
'evals.html': 'pro',
|
||
'guardrails.html': 'pro',
|
||
'meeting.html': 'pro',
|
||
'rag-quality.html': 'pro',
|
||
'router.html': 'pro',
|
||
'schedules.html': 'pro',
|
||
'teams.html': 'pro',
|
||
'training.html': 'pro',
|
||
'workflows.html': 'pro',
|
||
};
|
||
|
||
// Human-readable tier names for the upgrade prompt (Server S/M/L/Max are the
|
||
// commercial labels for the same starter/basic/pro/max ranks used above —
|
||
// slugs and TIER_RANK are unchanged for backward compatibility).
|
||
var TIER_NAMES = {
|
||
starter: 'Server S',
|
||
basic: 'Server M',
|
||
pro: 'Server L',
|
||
max: 'Server Max',
|
||
};
|
||
|
||
var SERVICE_PORTS = {
|
||
'3001': { name: 'open-webui', label: 'Open WebUI' },
|
||
'11434': { name: 'ollama', label: 'Ollama' },
|
||
'8000': { name: 'chromadb', label: 'ChromaDB' },
|
||
'8888': { name: 'jupyter', label: 'Jupyter' },
|
||
'8080': { name: 'cezen-api', label: 'Nexus API' },
|
||
};
|
||
|
||
// ── Upgrade modal (injected once into the DOM) ──────────────────────────────
|
||
function ensureModal() {
|
||
if (document.getElementById('cezen-upgrade-modal')) return;
|
||
var modal = document.createElement('div');
|
||
modal.id = 'cezen-upgrade-modal';
|
||
modal.innerHTML = [
|
||
'<div id="cezen-upgrade-backdrop"></div>',
|
||
'<div id="cezen-upgrade-box">',
|
||
' <div id="cezen-upgrade-icon"><span class="icon-svg icon-2xl" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span></div>',
|
||
' <h3 id="cezen-upgrade-title">Feature Locked</h3>',
|
||
' <p id="cezen-upgrade-body"></p>',
|
||
' <div id="cezen-upgrade-actions">',
|
||
' <a href="mailto:sales@cezentech.com" id="cezen-upgrade-cta">Contact Sales to Upgrade</a>',
|
||
' <button id="cezen-upgrade-close">Close</button>',
|
||
' </div>',
|
||
'</div>',
|
||
].join('');
|
||
document.body.appendChild(modal);
|
||
document.getElementById('cezen-upgrade-close').onclick = closeModal;
|
||
document.getElementById('cezen-upgrade-backdrop').onclick = closeModal;
|
||
}
|
||
|
||
function openModal(featureName, requiredTier) {
|
||
ensureModal();
|
||
var name = TIER_NAMES[requiredTier] || requiredTier;
|
||
document.getElementById('cezen-upgrade-title').textContent = featureName + ' — Upgrade Required';
|
||
document.getElementById('cezen-upgrade-body').textContent =
|
||
'This feature is available on the ' + name + ' tier and above. ' +
|
||
'Contact Cezentech to upgrade your Nexus One AI package.';
|
||
document.getElementById('cezen-upgrade-modal').classList.add('cezen-modal-open');
|
||
}
|
||
|
||
function openServiceModal(label) {
|
||
ensureModal();
|
||
document.getElementById('cezen-upgrade-title').textContent = label + ' is not running';
|
||
document.getElementById('cezen-upgrade-body').textContent =
|
||
'This service is not currently available on this appliance. Check the home page service status, or ask the administrator to enable and start the service for this tier.';
|
||
document.getElementById('cezen-upgrade-cta').style.display = 'none';
|
||
document.getElementById('cezen-upgrade-modal').classList.add('cezen-modal-open');
|
||
}
|
||
|
||
function closeModal() {
|
||
var m = document.getElementById('cezen-upgrade-modal');
|
||
if (m) m.classList.remove('cezen-modal-open');
|
||
var cta = document.getElementById('cezen-upgrade-cta');
|
||
if (cta) cta.style.display = '';
|
||
}
|
||
|
||
function currentBaseForPort(port) {
|
||
var protocol = location.protocol === 'https:' ? 'https:' : 'http:';
|
||
var host = location.hostname || 'ai.local';
|
||
return protocol + '//' + host + ':' + port;
|
||
}
|
||
|
||
function rewriteAiLocalText(value) {
|
||
if (!value || value.indexOf('ai.local') === -1) return value;
|
||
return value
|
||
.replace(/https?:\/\/ai\.local:3001/g, currentBaseForPort('3001'))
|
||
.replace(/https?:\/\/ai\.local:11434/g, currentBaseForPort('11434'))
|
||
.replace(/https?:\/\/ai\.local:8000/g, currentBaseForPort('8000'))
|
||
.replace(/https?:\/\/ai\.local:8888/g, currentBaseForPort('8888'))
|
||
.replace(/https?:\/\/ai\.local:8080/g, currentBaseForPort('8080'));
|
||
}
|
||
|
||
function normalizeApplianceLinks() {
|
||
document.querySelectorAll('a[href]').forEach(function (link) {
|
||
var href = link.getAttribute('href') || '';
|
||
if (href.indexOf('ai.local') !== -1) link.setAttribute('href', rewriteAiLocalText(href));
|
||
});
|
||
|
||
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||
acceptNode: function (node) {
|
||
var tag = node.parentNode && node.parentNode.tagName;
|
||
if (tag === 'SCRIPT' || tag === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||
return node.nodeValue.indexOf('ai.local') !== -1 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||
}
|
||
});
|
||
var textNodes = [];
|
||
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
||
textNodes.forEach(function (node) { node.nodeValue = rewriteAiLocalText(node.nodeValue); });
|
||
}
|
||
|
||
function serviceForHref(href) {
|
||
try {
|
||
var u = new URL(href, location.href);
|
||
return SERVICE_PORTS[u.port || (u.protocol === 'https:' ? '443' : '80')];
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function applyServiceAvailability() {
|
||
fetch('/api/services', { credentials: 'include' })
|
||
.then(function (r) { return r.ok ? r.json() : []; })
|
||
.then(function (services) {
|
||
var online = {};
|
||
services.forEach(function (svc) { online[svc.name] = !!svc.ok; });
|
||
document.querySelectorAll('a[href]').forEach(function (link) {
|
||
var svc = serviceForHref(link.getAttribute('href') || '');
|
||
if (!svc || online[svc.name]) return;
|
||
if (link.classList.contains('cezen-service-unavailable')) return;
|
||
link.classList.add('cezen-service-unavailable');
|
||
link.setAttribute('title', svc.label + ' is not running on this appliance');
|
||
link.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
openServiceModal(svc.label);
|
||
});
|
||
});
|
||
})
|
||
.catch(function () { /* Public pages or offline backend: leave links unchanged. */ });
|
||
}
|
||
|
||
// ── Apply tier locks to all nav links ──────────────────────────────────────
|
||
function applyTierGating(tierSlug) {
|
||
var rank = TIER_RANK[tierSlug] !== undefined ? TIER_RANK[tierSlug] : 1;
|
||
|
||
document.querySelectorAll('a[href]').forEach(function (link) {
|
||
var href = link.getAttribute('href') || '';
|
||
var file = href.split('/').pop().split('?')[0];
|
||
var minTier = FEATURE_TIERS[file];
|
||
if (!minTier) return;
|
||
|
||
var minRank = TIER_RANK[minTier] !== undefined ? TIER_RANK[minTier] : 99;
|
||
if (rank >= minRank) return; // user has access — do nothing
|
||
|
||
// Mark the link as locked
|
||
if (link.classList.contains('cezen-locked')) return; // already processed
|
||
link.classList.add('cezen-locked');
|
||
|
||
// Add lock badge after the link text (if not already there)
|
||
var badge = document.createElement('span');
|
||
badge.className = 'cezen-lock-badge';
|
||
badge.setAttribute('title', 'Requires ' + TIER_NAMES[minTier] + ' tier');
|
||
badge.innerHTML = '<span class="icon-svg icon-xs" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>';
|
||
link.appendChild(badge);
|
||
|
||
// Intercept click — show upgrade modal instead of navigating
|
||
var featureName = (link.textContent || file).trim();
|
||
link.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
openModal(featureName, minTier);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Main branding apply ─────────────────────────────────────────────────────
|
||
function applyBranding(b) {
|
||
var orgName = b.org_name || 'Nexus One AI';
|
||
var stackName = b.stack_name || 'Nexus One AI';
|
||
var logoUrl = b.logo_url || '';
|
||
var accent = b.accent_color || '#0D9488';
|
||
var footer = b.footer_text || 'Powered by Cezen';
|
||
var tier = b.tier_label || 'Basic Tier';
|
||
var tierSlug = b.tier_slug || 'basic';
|
||
|
||
ensureApplianceNavLink();
|
||
ensureAboutNavLink();
|
||
normalizeApplianceLinks();
|
||
applyServiceAvailability();
|
||
|
||
// ── Accent color CSS variable ───────────────────────────────────────────
|
||
document.documentElement.style.setProperty('--accent', accent);
|
||
document.documentElement.style.setProperty('--accent-dark', shadeColor(accent, -20));
|
||
|
||
// ── Page <title> ────────────────────────────────────────────────────────
|
||
if (document.title) {
|
||
document.title = document.title
|
||
.replace(/Cezen AI Suite/gi, stackName)
|
||
.replace(/Cezen AI/gi, orgName)
|
||
.replace(/Nexus One AI/gi, stackName);
|
||
}
|
||
|
||
// ── data-brand text nodes ───────────────────────────────────────────────
|
||
document.querySelectorAll('[data-brand="org"]').forEach(function (el) {
|
||
el.textContent = orgName;
|
||
});
|
||
document.querySelectorAll('[data-brand="stack"]').forEach(function (el) {
|
||
el.textContent = stackName;
|
||
});
|
||
document.querySelectorAll('[data-brand="tier"]').forEach(function (el) {
|
||
el.textContent = tier;
|
||
// Set slug for CSS tier-colour targeting
|
||
if (el.classList.contains('nav-tier-badge')) {
|
||
el.setAttribute('data-tier-slug', tierSlug);
|
||
}
|
||
});
|
||
document.querySelectorAll('[data-brand="footer"]').forEach(function (el) {
|
||
el.textContent = footer;
|
||
});
|
||
|
||
// ── Replace hardcoded text strings ─────────────────────────────────────
|
||
replaceText('.sidebar-brand-text, .brand-name, .navbar-brand', 'Cezen AI Suite', stackName);
|
||
replaceText('.sidebar-brand-text, .brand-name, .navbar-brand', 'Cezen AI', orgName);
|
||
replaceText('.sidebar-brand-text, .brand-name, .navbar-brand', 'Nexus One AI', orgName);
|
||
replaceText('.tier-badge, .tier-label', 'Basic Tier', tier);
|
||
replaceText('.tier-badge, .tier-label', 'Starter Tier', tier);
|
||
replaceText('footer, .footer-text', 'Powered by Cezen', footer);
|
||
|
||
// ── Logo ────────────────────────────────────────────────────────────────
|
||
if (logoUrl) {
|
||
var navLogoSlot = document.getElementById('nav-org-logo');
|
||
if (navLogoSlot) {
|
||
var navImg = document.createElement('img');
|
||
navImg.src = logoUrl;
|
||
navImg.alt = orgName + ' Logo';
|
||
navLogoSlot.innerHTML = '';
|
||
navLogoSlot.appendChild(navImg);
|
||
}
|
||
document.querySelectorAll('.brand-logo, [data-brand="logo"]').forEach(function (el) {
|
||
if (el.tagName === 'IMG') {
|
||
el.src = logoUrl;
|
||
el.alt = orgName + ' Logo';
|
||
} else {
|
||
var img = document.createElement('img');
|
||
img.src = logoUrl;
|
||
img.alt = orgName + ' Logo';
|
||
img.style.cssText = 'max-height:36px;width:auto;object-fit:contain;';
|
||
el.innerHTML = '';
|
||
el.appendChild(img);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Accent <style> block ────────────────────────────────────────────────
|
||
var style = document.getElementById('cezen-brand-style');
|
||
if (!style) {
|
||
style = document.createElement('style');
|
||
style.id = 'cezen-brand-style';
|
||
document.head.appendChild(style);
|
||
}
|
||
style.textContent = [
|
||
':root { --accent: ' + accent + '; --accent-dark: ' + shadeColor(accent, -20) + '; }',
|
||
'a.brand-accent { color: ' + accent + ' !important; }',
|
||
/* btn-primary intentionally excluded — brand colour is fixed in style.css */
|
||
'.btn-accent { background: ' + accent + ' !important; border-color: ' + accent + ' !important; }',
|
||
'.btn-accent:hover { background: ' + shadeColor(accent, -20) + ' !important; }',
|
||
'.sidebar-nav .nav-link.active { border-left-color: ' + accent + ' !important; color: ' + accent + ' !important; }',
|
||
'.progress-bar { background-color: ' + accent + ' !important; }',
|
||
].join('\n');
|
||
|
||
// ── Tier feature gating ─────────────────────────────────────────────────
|
||
applyTierGating(tierSlug);
|
||
injectTierGatingStyles();
|
||
}
|
||
|
||
// ── Tier gating CSS (injected once) ────────────────────────────────────────
|
||
function injectTierGatingStyles() {
|
||
if (document.getElementById('cezen-tier-style')) return;
|
||
var s = document.createElement('style');
|
||
s.id = 'cezen-tier-style';
|
||
s.textContent = [
|
||
/* Locked nav links */
|
||
'a.cezen-locked {',
|
||
' opacity: 0.55;',
|
||
' cursor: not-allowed !important;',
|
||
' position: relative;',
|
||
'}',
|
||
'a.cezen-locked:hover { opacity: 0.7; }',
|
||
|
||
'a.cezen-service-unavailable {',
|
||
' opacity: 0.58;',
|
||
' cursor: not-allowed !important;',
|
||
'}',
|
||
'a.cezen-service-unavailable::after {',
|
||
' content: " offline";',
|
||
' display: inline-block;',
|
||
' margin-left: 6px;',
|
||
' color: #dc2626;',
|
||
' font-size: 0.75em;',
|
||
' font-weight: 700;',
|
||
'}',
|
||
|
||
/* Lock badge */
|
||
'.cezen-lock-badge {',
|
||
' font-size: 0.65em;',
|
||
' margin-left: 5px;',
|
||
' vertical-align: middle;',
|
||
' pointer-events: none;',
|
||
'}',
|
||
|
||
/* Upgrade modal backdrop */
|
||
'#cezen-upgrade-modal {',
|
||
' display: none;',
|
||
' position: fixed;',
|
||
' inset: 0;',
|
||
' z-index: 99999;',
|
||
' align-items: center;',
|
||
' justify-content: center;',
|
||
'}',
|
||
'#cezen-upgrade-modal.cezen-modal-open { display: flex; }',
|
||
|
||
'#cezen-upgrade-backdrop {',
|
||
' position: absolute;',
|
||
' inset: 0;',
|
||
' background: rgba(15,23,42,0.65);',
|
||
' backdrop-filter: blur(3px);',
|
||
'}',
|
||
|
||
/* Modal box */
|
||
'#cezen-upgrade-box {',
|
||
' position: relative;',
|
||
' background: var(--surface, #fff);',
|
||
' border-radius: var(--radius-lg, 14px);',
|
||
' padding: 40px 36px 32px;',
|
||
' max-width: 420px;',
|
||
' width: 90%;',
|
||
' text-align: center;',
|
||
' box-shadow: var(--shadow-lg, 0 24px 60px rgba(0,0,0,0.22));',
|
||
' animation: cezen-modal-in 0.2s ease;',
|
||
'}',
|
||
'@keyframes cezen-modal-in {',
|
||
' from { opacity:0; transform: scale(0.92) translateY(12px); }',
|
||
' to { opacity:1; transform: scale(1) translateY(0); }',
|
||
'}',
|
||
|
||
'#cezen-upgrade-icon { color: var(--brand, #0D9488); margin-bottom: 10px; display: flex; justify-content: center; }',
|
||
|
||
'#cezen-upgrade-title {',
|
||
' font-size: 1.15rem;',
|
||
' font-weight: 700;',
|
||
' color: var(--text-primary, #0f172a);',
|
||
' margin: 0 0 10px;',
|
||
'}',
|
||
|
||
'#cezen-upgrade-body {',
|
||
' font-size: 0.9rem;',
|
||
' color: var(--text-secondary, #475569);',
|
||
' line-height: 1.55;',
|
||
' margin: 0 0 24px;',
|
||
'}',
|
||
|
||
'#cezen-upgrade-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }',
|
||
|
||
'#cezen-upgrade-cta {',
|
||
' display: inline-block;',
|
||
' background: var(--accent, #0D9488);',
|
||
' color: #fff !important;',
|
||
' padding: 9px 20px;',
|
||
' border-radius: var(--radius-md, 7px);',
|
||
' font-size: 0.875rem;',
|
||
' font-weight: 600;',
|
||
' text-decoration: none;',
|
||
' transition: background 0.18s;',
|
||
'}',
|
||
'#cezen-upgrade-cta:hover { background: var(--accent-dark, #0b7a6f); }',
|
||
|
||
'#cezen-upgrade-close {',
|
||
' background: var(--bg, #f1f5f9);',
|
||
' border: none;',
|
||
' border-radius: var(--radius-md, 7px);',
|
||
' padding: 9px 20px;',
|
||
' font-size: 0.875rem;',
|
||
' color: var(--text-secondary, #334155);',
|
||
' cursor: pointer;',
|
||
' transition: background 0.18s;',
|
||
'}',
|
||
'#cezen-upgrade-close:hover { background: var(--border, #e2e8f0); }',
|
||
].join('\n');
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function replaceText(selector, from, to) {
|
||
try {
|
||
document.querySelectorAll(selector).forEach(function (el) {
|
||
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === Node.TEXT_NODE) {
|
||
el.textContent = el.textContent.replace(
|
||
new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), to
|
||
);
|
||
}
|
||
});
|
||
} catch (e) { /* selector may not exist */ }
|
||
}
|
||
|
||
function ensureApplianceNavLink() {
|
||
if (document.querySelector('a[href="appliance.html"]')) return;
|
||
document.querySelectorAll('.nav-drop-cat').forEach(function (cat) {
|
||
if ((cat.textContent || '').trim().toUpperCase() !== 'SYSTEM /') return;
|
||
var next = cat.nextElementSibling;
|
||
var link = document.createElement('a');
|
||
link.href = 'appliance.html';
|
||
link.textContent = 'Appliance Ops';
|
||
if (location.pathname.endsWith('/appliance.html')) link.className = 'active';
|
||
cat.parentNode.insertBefore(link, next || null);
|
||
});
|
||
}
|
||
|
||
// Adds "About" to the end of the SYSTEM nav group on every page (except
|
||
// about.html itself, which already has it hardcoded and marked active).
|
||
function ensureAboutNavLink() {
|
||
if (document.querySelector('a[href="about.html"]')) return;
|
||
document.querySelectorAll('.nav-drop-cat').forEach(function (cat) {
|
||
if ((cat.textContent || '').trim().toUpperCase() !== 'SYSTEM /') return;
|
||
var sib = cat.nextElementSibling;
|
||
while (sib && sib.tagName === 'A') { sib = sib.nextElementSibling; }
|
||
var link = document.createElement('a');
|
||
link.href = 'about.html';
|
||
link.textContent = 'About';
|
||
cat.parentNode.insertBefore(link, sib || null);
|
||
});
|
||
}
|
||
|
||
// ── License status labels (window.licenseStatusLabel) ──────────────────────
|
||
// Raw codes from the backend's license evaluation (valid/missing/expired/
|
||
// invalid_signature/not_yet_valid/machine_mismatch) aren't meant for display
|
||
// — translate them the same way /api/health does, everywhere the portal
|
||
// shows a license status to a user.
|
||
var LICENSE_STATUS_LABELS = {
|
||
valid: 'Valid',
|
||
missing: 'No license installed (field-staging mode)',
|
||
expired: 'Expired — contact support@cezentech.com',
|
||
invalid_signature: 'Invalid signature — re-upload a valid signed license',
|
||
not_yet_valid: 'Not yet in its valid date range',
|
||
machine_mismatch: 'Bound to different hardware — contact support@cezentech.com',
|
||
};
|
||
window.licenseStatusLabel = function (status) {
|
||
return LICENSE_STATUS_LABELS[status] || (status || 'Unknown');
|
||
};
|
||
|
||
function shadeColor(hex, pct) {
|
||
var num = parseInt(hex.replace('#', ''), 16);
|
||
var r = Math.min(255, Math.max(0, (num >> 16) + Math.round(2.55 * pct)));
|
||
var g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + Math.round(2.55 * pct)));
|
||
var b = Math.min(255, Math.max(0, (num & 0xff) + Math.round(2.55 * pct)));
|
||
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||
}
|
||
|
||
// ── Shared toast notifications (window.showToast) ──────────────────────────
|
||
// Single source of truth for transient messages — replaces the assorted
|
||
// per-page toast divs and native alert() calls. See style.css for
|
||
// #cezen-toast-stack / .cezen-toast styling.
|
||
var TOAST_ICONS = {
|
||
success: '<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
|
||
error: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>',
|
||
warn: '<svg viewBox="0 0 24 24"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
|
||
info: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
|
||
};
|
||
|
||
function ensureToastStack() {
|
||
var stack = document.getElementById('cezen-toast-stack');
|
||
if (!stack) {
|
||
stack = document.createElement('div');
|
||
stack.id = 'cezen-toast-stack';
|
||
document.body.appendChild(stack);
|
||
}
|
||
return stack;
|
||
}
|
||
|
||
window.showToast = function (message, type, opts) {
|
||
type = TOAST_ICONS[type] ? type : 'info';
|
||
opts = opts || {};
|
||
var duration = opts.duration !== undefined ? opts.duration : (type === 'error' ? 6000 : 4000);
|
||
var stack = ensureToastStack();
|
||
var el = document.createElement('div');
|
||
el.className = 'cezen-toast ' + type;
|
||
el.innerHTML =
|
||
'<span class="icon-svg icon-sm" aria-hidden="true">' + TOAST_ICONS[type] + '</span>' +
|
||
'<span class="cezen-toast-body"></span>' +
|
||
'<button class="cezen-toast-close" aria-label="Dismiss">×</button>';
|
||
el.querySelector('.cezen-toast-body').textContent = message;
|
||
function remove() {
|
||
el.classList.add('leaving');
|
||
setTimeout(function () { if (el.parentNode) el.remove(); }, 160);
|
||
}
|
||
el.querySelector('.cezen-toast-close').onclick = remove;
|
||
stack.appendChild(el);
|
||
if (duration > 0) setTimeout(remove, duration);
|
||
return el;
|
||
};
|
||
|
||
// ── Shared confirmation modal (window.showConfirm) ──────────────────────────
|
||
// Replaces native confirm() with a styled, on-brand modal.
|
||
// Usage: showConfirm('Delete user?', 'This cannot be undone.', {danger:true})
|
||
// .then(function (ok) { if (ok) { ...proceed... } });
|
||
function ensureConfirmModal() {
|
||
var modal = document.getElementById('cezen-confirm-modal');
|
||
if (modal) return modal;
|
||
modal = document.createElement('div');
|
||
modal.id = 'cezen-confirm-modal';
|
||
modal.innerHTML =
|
||
'<div id="cezen-confirm-backdrop"></div>' +
|
||
'<div id="cezen-confirm-box">' +
|
||
' <div id="cezen-confirm-title"></div>' +
|
||
' <div id="cezen-confirm-body"></div>' +
|
||
' <div id="cezen-confirm-actions">' +
|
||
' <button id="cezen-confirm-cancel" class="btn-secondary">Cancel</button>' +
|
||
' <button id="cezen-confirm-ok" class="btn-primary">Confirm</button>' +
|
||
' </div>' +
|
||
'</div>';
|
||
document.body.appendChild(modal);
|
||
return modal;
|
||
}
|
||
|
||
window.showConfirm = function (title, body, opts) {
|
||
opts = opts || {};
|
||
return new Promise(function (resolve) {
|
||
var modal = ensureConfirmModal();
|
||
modal.querySelector('#cezen-confirm-title').textContent = title || 'Are you sure?';
|
||
modal.querySelector('#cezen-confirm-body').textContent = body || '';
|
||
var okBtn = modal.querySelector('#cezen-confirm-ok');
|
||
var cancelBtn = modal.querySelector('#cezen-confirm-cancel');
|
||
okBtn.textContent = opts.confirmLabel || 'Confirm';
|
||
cancelBtn.textContent = opts.cancelLabel || 'Cancel';
|
||
okBtn.className = opts.danger ? 'btn-danger' : 'btn-primary';
|
||
var backdrop = modal.querySelector('#cezen-confirm-backdrop');
|
||
function close(result) {
|
||
modal.classList.remove('cezen-modal-open');
|
||
okBtn.onclick = null; cancelBtn.onclick = null; backdrop.onclick = null;
|
||
resolve(result);
|
||
}
|
||
okBtn.onclick = function () { close(true); };
|
||
cancelBtn.onclick = function () { close(false); };
|
||
backdrop.onclick = function () { close(false); };
|
||
modal.classList.add('cezen-modal-open');
|
||
});
|
||
};
|
||
|
||
// ── Global command palette (window search / Ctrl+K) ─────────────────────────
|
||
// No global or cross-entity search existed anywhere in the portal — only a
|
||
// handful of pages had a local filter box. This adds a lightweight, static
|
||
// "jump to" index of every page in the product, reachable from any screen
|
||
// via Ctrl/Cmd+K or the search icon in the top nav. It's navigation search,
|
||
// not a live database query — no new API surface, matches the rest of
|
||
// branding.js's pattern of injecting shared UI once per page load.
|
||
var CMDK_PAGES = [
|
||
{ label: 'Home', href: 'index.html', cat: 'General' },
|
||
{ label: 'Quick Start', href: 'quickstart.html', cat: 'General' },
|
||
{ label: 'Prompt Library', href: 'prompts.html', cat: 'General' },
|
||
{ label: 'Use Cases', href: 'usecases.html', cat: 'General' },
|
||
{ label: 'Model Library', href: 'models.html', cat: 'Learn' },
|
||
{ label: 'Troubleshooting', href: 'troubleshooting.html', cat: 'Support' },
|
||
{ label: 'FAQ', href: 'faq.html', cat: 'Support' },
|
||
{ label: 'Glossary', href: 'glossary.html', cat: 'More' },
|
||
{ label: "What's New", href: 'whats-new.html', cat: 'More' },
|
||
{ label: 'Security & Privacy', href: 'security.html', cat: 'Docs' },
|
||
{ label: 'Admin Guide', href: 'admin.html', cat: 'Docs' },
|
||
{ label: 'Dashboard', href: 'dashboard.html', cat: 'Monitor' },
|
||
{ label: 'Usage Analytics', href: 'analytics.html', cat: 'Monitor' },
|
||
{ label: 'Governance & Audit', href: 'audit.html', cat: 'Monitor' },
|
||
{ label: 'Health Center', href: 'health.html', cat: 'Monitor', keywords: 'status services gpu database' },
|
||
{ label: 'Feedback & Ratings', href: 'feedback.html', cat: 'Monitor' },
|
||
{ label: 'Users', href: 'users.html', cat: 'Manage', keywords: 'user management accounts' },
|
||
{ label: 'Teams', href: 'teams.html', cat: 'Manage' },
|
||
{ label: 'Model Catalog', href: 'models-admin.html', cat: 'Manage', keywords: 'models install pull upload' },
|
||
{ label: 'Training', href: 'training.html', cat: 'Manage', keywords: 'fine-tune qlora' },
|
||
{ label: 'Knowledge Base', href: 'knowledge.html', cat: 'Manage', keywords: 'rag documents collections kb' },
|
||
{ label: 'API Keys', href: 'apikeys.html', cat: 'Tools' },
|
||
{ label: 'Benchmarking', href: 'benchmark.html', cat: 'Tools', keywords: 'model benchmark speed tokens' },
|
||
{ label: 'Model Compare', href: 'model-compare.html', cat: 'Tools' },
|
||
{ label: 'API Playground', href: 'api-playground.html', cat: 'Tools' },
|
||
{ label: 'Guardrails', href: 'guardrails.html', cat: 'Tools', keywords: 'content filters pii keyword' },
|
||
{ label: 'RAG Quality', href: 'rag-quality.html', cat: 'Tools' },
|
||
{ label: 'Model Router', href: 'router.html', cat: 'Tools' },
|
||
{ label: 'MCP & Enterprise Connectors', href: 'connectors.html', cat: 'Tools', keywords: 'database folder connector integration' },
|
||
{ label: 'Console', href: 'console.html', cat: 'System' },
|
||
{ label: 'Settings', href: 'settings.html', cat: 'System' },
|
||
{ label: 'Appliance Ops', href: 'appliance.html', cat: 'System' },
|
||
{ label: 'Document Intelligence', href: 'documents.html', cat: 'AI Tools' },
|
||
{ label: 'AI Workspace', href: 'chat-multi.html', cat: 'AI Tools', keywords: 'multimodal chat' },
|
||
{ label: 'Prompt Studio', href: 'prompt-studio.html', cat: 'AI Tools' },
|
||
{ label: 'Meeting Assistant', href: 'meeting.html', cat: 'AI Tools' },
|
||
{ label: 'Agent Studio', href: 'agents.html', cat: 'AI Tools', keywords: 'agents automation' },
|
||
{ label: 'Scheduled Jobs', href: 'schedules.html', cat: 'AI Tools', keywords: 'cron automation jobs' },
|
||
{ label: 'Workflow Designer', href: 'workflows.html', cat: 'AI Tools', keywords: 'automation pipeline' },
|
||
{ label: 'AI Eval Suite', href: 'evals.html', cat: 'AI Tools', keywords: 'evaluation test suite' },
|
||
{ label: 'Chat Rooms', href: 'chatrooms.html', cat: 'AI Tools' },
|
||
{ label: 'Notifications', href: 'notifications.html', cat: 'General' },
|
||
];
|
||
|
||
function ensureCmdPalette() {
|
||
var modal = document.getElementById('cezen-cmdk');
|
||
if (modal) return modal;
|
||
modal = document.createElement('div');
|
||
modal.id = 'cezen-cmdk';
|
||
modal.innerHTML =
|
||
'<div id="cezen-cmdk-backdrop"></div>' +
|
||
'<div id="cezen-cmdk-box">' +
|
||
' <div id="cezen-cmdk-input-wrap">' +
|
||
' <span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg></span>' +
|
||
' <input id="cezen-cmdk-input" type="text" placeholder="Jump to a page…" autocomplete="off">' +
|
||
' <span id="cezen-cmdk-hint">Esc</span>' +
|
||
' </div>' +
|
||
' <div id="cezen-cmdk-results"></div>' +
|
||
'</div>';
|
||
document.body.appendChild(modal);
|
||
var input = modal.querySelector('#cezen-cmdk-input');
|
||
modal.querySelector('#cezen-cmdk-backdrop').onclick = closeCmdPalette;
|
||
input.addEventListener('input', function () { renderCmdResults(input.value); });
|
||
input.addEventListener('keydown', function (e) {
|
||
var results = modal.querySelectorAll('.cezen-cmdk-item');
|
||
if (!results.length) return;
|
||
var activeIdx = -1;
|
||
results.forEach(function (r, i) { if (r.classList.contains('active')) activeIdx = i; });
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
activeIdx = (activeIdx + 1) % results.length;
|
||
results.forEach(function (r, i) { r.classList.toggle('active', i === activeIdx); });
|
||
results[activeIdx].scrollIntoView({ block: 'nearest' });
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
activeIdx = activeIdx <= 0 ? results.length - 1 : activeIdx - 1;
|
||
results.forEach(function (r, i) { r.classList.toggle('active', i === activeIdx); });
|
||
results[activeIdx].scrollIntoView({ block: 'nearest' });
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
var target = modal.querySelector('.cezen-cmdk-item.active') || results[0];
|
||
if (target) target.click();
|
||
}
|
||
});
|
||
return modal;
|
||
}
|
||
|
||
function renderCmdResults(query) {
|
||
var modal = document.getElementById('cezen-cmdk');
|
||
var resultsEl = modal.querySelector('#cezen-cmdk-results');
|
||
var q = (query || '').trim().toLowerCase();
|
||
var matches = CMDK_PAGES.filter(function (p) {
|
||
if (!q) return true;
|
||
var hay = (p.label + ' ' + p.cat + ' ' + (p.keywords || '')).toLowerCase();
|
||
return hay.indexOf(q) !== -1;
|
||
}).slice(0, 8);
|
||
if (!matches.length) {
|
||
resultsEl.innerHTML = '<div id="cezen-cmdk-empty">No pages match "' + escapeHtml(query) + '".</div>';
|
||
return;
|
||
}
|
||
resultsEl.innerHTML = matches.map(function (p, i) {
|
||
return '<a class="cezen-cmdk-item' + (i === 0 ? ' active' : '') + '" href="' + p.href + '">' +
|
||
'<span class="cezen-cmdk-item-label">' + escapeHtml(p.label) + '</span>' +
|
||
'<span class="cezen-cmdk-item-cat">' + escapeHtml(p.cat) + '</span></a>';
|
||
}).join('');
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return (s || '').replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
function openCmdPalette() {
|
||
var modal = ensureCmdPalette();
|
||
modal.classList.add('cezen-cmdk-open');
|
||
var input = modal.querySelector('#cezen-cmdk-input');
|
||
input.value = '';
|
||
renderCmdResults('');
|
||
setTimeout(function () { input.focus(); }, 0);
|
||
}
|
||
|
||
function closeCmdPalette() {
|
||
var modal = document.getElementById('cezen-cmdk');
|
||
if (modal) modal.classList.remove('cezen-cmdk-open');
|
||
}
|
||
|
||
function injectCmdPaletteTrigger() {
|
||
if (document.querySelector('.nav-search-btn')) return;
|
||
var topnav = document.querySelector('.topnav');
|
||
if (!topnav) return; // page has no shared topnav (e.g. login) — skip
|
||
var navMenu = topnav.querySelector(':scope > nav');
|
||
if (!navMenu) return;
|
||
var btn = document.createElement('button');
|
||
btn.className = 'nav-icon-btn nav-search-btn';
|
||
btn.type = 'button';
|
||
btn.setAttribute('aria-label', 'Search (Ctrl+K)');
|
||
btn.title = 'Search (Ctrl+K)';
|
||
btn.innerHTML = '<span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg></span>';
|
||
btn.onclick = openCmdPalette;
|
||
topnav.insertBefore(btn, navMenu.nextSibling);
|
||
}
|
||
|
||
// Two topnav markup variants exist across the portal: some pages wrap the
|
||
// logo/tier-badge/notification-bell trio in a .nav-right div (in that DOM
|
||
// order), others place the same three elements as flat topnav children in
|
||
// the REVERSE order (bell, badge, logo). Relying on CSS `order` to reconcile
|
||
// this is fragile because .nav-right is itself a nested flex container, so
|
||
// `order` values only resolve within whichever flex context an element
|
||
// actually sits in. Instead, physically move all four icon-cluster elements
|
||
// into one canonical container in one canonical DOM order, so every page's
|
||
// rendered markup is identical after this runs — no reliance on `order` math.
|
||
function normalizeNavIconCluster() {
|
||
var topnav = document.querySelector('.topnav');
|
||
if (!topnav) return;
|
||
var search = topnav.querySelector('.nav-search-btn');
|
||
var bell = topnav.querySelector('a.nav-icon-btn[href="notifications.html"]');
|
||
var badge = topnav.querySelector('[data-brand="tier"]');
|
||
var logo = topnav.querySelector('#nav-org-logo');
|
||
if (!bell && !badge && !logo) return; // nothing to normalize on this page
|
||
|
||
var cluster = topnav.querySelector('.nav-right');
|
||
if (!cluster) {
|
||
cluster = document.createElement('div');
|
||
cluster.className = 'nav-right';
|
||
topnav.appendChild(cluster);
|
||
}
|
||
// Canonical left-to-right order: search, bell, tier badge, org logo.
|
||
// appendChild on an already-attached node MOVES it, so this deterministically
|
||
// reorders in place regardless of the page's original markup pattern.
|
||
[search, bell, badge, logo].forEach(function (el) {
|
||
if (el) cluster.appendChild(el);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
var isMeta = e.metaKey || e.ctrlKey;
|
||
if (isMeta && e.key.toLowerCase() === 'k') {
|
||
e.preventDefault();
|
||
var modal = document.getElementById('cezen-cmdk');
|
||
if (modal && modal.classList.contains('cezen-cmdk-open')) closeCmdPalette();
|
||
else openCmdPalette();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
var m = document.getElementById('cezen-cmdk');
|
||
if (m && m.classList.contains('cezen-cmdk-open')) closeCmdPalette();
|
||
}
|
||
});
|
||
|
||
function setupTopnavIcons() {
|
||
injectCmdPaletteTrigger();
|
||
normalizeNavIconCluster();
|
||
}
|
||
document.addEventListener('DOMContentLoaded', setupTopnavIcons);
|
||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||
setupTopnavIcons();
|
||
}
|
||
|
||
// ── Fetch branding and apply ────────────────────────────────────────────────
|
||
fetch('/api/settings/branding', { credentials: 'include' })
|
||
.then(function (r) { return r.ok ? r.json() : {}; })
|
||
.then(function (data) { applyBranding(data); })
|
||
.catch(function () { applyBranding({}); }); // fallback: apply defaults silently
|
||
})();
|