1/* docs/components/sidebar/site-sidebar.js 2 * Injects a professional sidebar and wires it to showSection(). 3 */ 4(function () { 5 'use strict'; 6 7 const SIDEBAR_ID = 'site-sidebar'; 8 const MOUNT_ID = 'sidebar-mount'; 9 10 function qs(id) { 11 return document.getElementById(id); 12 } 13 14 async function fetchText(url) { 15 const resp = await fetch(url, { cache: 'no-store' }); 16 if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`); 17 return await resp.text(); 18 } 19 20 async function injectCssOnce(href) { 21 const already = document.querySelector(`link[rel="stylesheet"][href="${href}"]`); 22 if (already) return; 23 24 const link = document.createElement('link'); 25 link.rel = 'stylesheet'; 26 link.href = href; 27 document.head.appendChild(link); 28 } 29 30 function setActiveLink(root, activeAnchorEl) { 31 root.querySelectorAll('a.site-sidebar__link').forEach((a) => { 32 a.classList.toggle('is-active', a === activeAnchorEl); 33 }); 34 } 35 36 function wire(root) { 37 root.addEventListener('click', (e) => { 38 const a = e.target.closest('a[data-section]'); 39 if (!a) return; 40 41 e.preventDefault(); 42 43 const section = a.getAttribute('data-section') || ''; 44 const target = a.getAttribute('data-target') || ''; 45 const emph = a.getAttribute('data-emph') || ''; 46 47 // Pre-select mission service before navigation (so loadMission renders the right one) 48 if (target && target.startsWith('#svc-') && typeof window.HAIPHEN?.selectMissionService === 'function') { 49 window.HAIPHEN.selectMissionService(target.replace('#svc-', '')); 50 } 51 52 // Prefer deep-link navigation if available 53 if (typeof window.HAIPHEN?.sidebarNavigate === 'function' && section) { 54 window.HAIPHEN.sidebarNavigate({ 55 section, 56 target: target || null, 57 emphasize: emph || target || null, 58 }); 59 setActiveLink(root, a); 60 return; 61 } 62 63 // Fallback: use path-based nav if available, else hash nav 64 if (section && typeof window.navigateTo === 'function') { 65 window.navigateTo(section); 66 setActive(root, section); 67 } else if (section && typeof window.showSection === 'function') { 68 window.showSection(section); 69 setActive(root, section); 70 } 71 72 // Legacy optional behavior 73 if (a.getAttribute('data-action') === 'scroll-ethos' && typeof window.scrollToEthos === 'function') { 74 window.scrollToEthos(); 75 } 76 }); 77 } 78 79 function insertSidebarSessionCard(mountEl) { 80 const slot = mountEl.querySelector('#sidebar-session-card'); 81 if (!slot) return; 82 83 const AUTH_ORIGIN = window.HAIPHEN?.AUTH_ORIGIN || 'https://auth.haiphen.io'; 84 const LOGOUT_URL = `${AUTH_ORIGIN}/logout`; 85 86 function loginHref(provider) { 87 const here = window.location.href; 88 const u = new URL(`${AUTH_ORIGIN}/login`); 89 u.searchParams.set('to', here); 90 u.searchParams.set('provider', provider || 'google'); 91 return u.toString(); 92 } 93 94 async function authMe() { 95 try { 96 const r = await fetch(`${AUTH_ORIGIN}/me`, { 97 credentials: 'include', 98 cache: 'no-store', 99 }); 100 if (!r.ok) return { ok: false, status: r.status, user: null }; 101 const user = await r.json().catch(() => null); 102 return { ok: true, status: r.status, user }; 103 } catch { 104 return { ok: false, status: 0, user: null }; 105 } 106 } 107 108 function renderLoggedOut() { 109 const siteKey = window.HAIPHEN?.TURNSTILE_SITE_KEY || ''; 110 111 slot.innerHTML = ` 112 <div class="sidebar-session"> 113 <div class="sidebar-session__title">Session</div> 114 <div class="sidebar-session__sub">You're not logged in.</div> 115 <div class="sidebar-login-buttons"> 116 <button type="button" class="sidebar-login-btn sidebar-login-btn--github" data-provider="github"> 117 <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg> 118 GitHub 119 </button> 120 <button type="button" class="sidebar-login-btn sidebar-login-btn--google" data-provider="google"> 121 <svg width="14" height="14" viewBox="0 0 48 48" aria-hidden="true"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg> 122 Google 123 </button> 124 </div> 125 ${siteKey ? `<div class="sidebar-turnstile" data-turnstile-container style="margin-top:8px;"></div>` : ''} 126 </div> 127 `; 128 slot.hidden = false;
129 130 // Wire Turnstile-gated login 131 const btns = slot.querySelectorAll('[data-provider]'); 132 const container = slot.querySelector('[data-turnstile-container]'); 133 134 if (siteKey && typeof turnstile !== 'undefined' && container) { 135 turnstile.render(container, { 136 sitekey: siteKey, 137 size: 'compact', 138 callback: function (token) { 139 container.dataset.cfToken = token; 140 }, 141 }); 142 143 btns.forEach(function (btn) { 144 btn.addEventListener('click', function () { 145 const token = container?.dataset.cfToken; 146 if (!token) { 147 container.style.outline = '2px solid #EA4335'; 148 container.style.borderRadius = '4px'; 149 return; 150 } 151 const href = loginHref(btn.dataset.provider); 152 window.location.assign(href + '&cf_token=' + encodeURIComponent(token)); 153 }); 154 }); 155 } else { 156 // No Turnstile â plain redirect 157 btns.forEach(function (btn) { 158 btn.addEventListener('click', function () { 159 window.location.assign(loginHref(btn.dataset.provider)); 160 }); 161 }); 162 } 163 } 164 165 // Map nav items to profile sub-sections or SPA sections 166 const NAV_MAP = { 167 profile: { section: 'Profile', tab: 'overview', hash: '#profile' }, 168 settings: { section: 'Profile', tab: 'settings', hash: '#profile/settings' }, 169 billing: { section: 'Profile', tab: 'billing', hash: '#profile/billing' }, 170 quota: { section: 'Profile', tab: 'billing', hash: '#profile/billing' }, 171 apikeys: { section: 'Profile', tab: 'apikeys', hash: '#profile/apikeys' }, 172 'getting-started': { section: 'GettingStarted', hash: '#getting-started' }, 173 }; 174 175 function sidebarMenuHtml(user) { 176 const name = user?.name || user?.sub || 'User'; 177 const email = user?.email || 'â'; 178 const sub = user?.sub || 'â'; 179 const avatar = user?.avatar || user?.avatar_url || ''; 180 181 return ` 182 <div class="sidebar-session-menu" data-sidebar-session-menu> 183 <div class="sidebar-session-trigger" tabindex="0" role="button" aria-haspopup="true" aria-expanded="false"> 184 <div class="sidebar-session-left"> 185 <div class="sidebar-session-title">${escapeHtml(name)}</div> 186 <div class="sidebar-session-sub">${escapeHtml(email)}</div> 187 </div> 188 ${avatar && /^https:\/\//.test(avatar) ? `<img class="sidebar-session-avatar" src="${escapeAttr(avatar)}" alt="" aria-hidden="true" />` : ''} 189 </div> 190 191 <div class="sidebar-session-dropdown" role="menu" aria-label="Session menu"> 192 <nav class="sidebar-session-links"> 193 <a class="sidebar-session-link" href="javascript:void(0)" role="menuitem" data-session-nav="profile"> 194 Profile 195 </a> 196 <a class="sidebar-session-link" href="javascript:void(0)" role="menuitem" data-session-nav="getting-started"> 197 Getting Started 198 </a> 199 <a class="sidebar-session-link" href="javascript:void(0)" role="menuitem" data-session-nav="settings"> 200 Settings 201 </a> 202 <a class="sidebar-session-link" href="javascript:void(0)" role="menuitem" data-session-nav="apikeys"> 203 API Keys 204 </a> 205 <a class="sidebar-session-link" href="javascript:void(0)" role="menuitem" data-session-nav="billing"> 206 Billing & Plan 207 </a> 208 <a class="sidebar-session-link" href="javascript:void(0)" role="menuitem" data-session-nav="quota"> 209 Rate Limits & Quota 210 </a> 211 </nav> 212 213 <div class="sidebar-session-divider"></div> 214 215 <button class="sidebar-session-btn" type="button" role="menuitem" data-session-action="switch-account"> 216 Switch Account 217 </button> 218 <button class="sidebar-session-btn" type="button" role="menuitem" data-se
218ssion-action="logout"> 219 Logout 220 </button> 221 </div> 222 </div> 223 `; 224 } 225 226 function wireSidebarMenu() { 227 const menu = slot.querySelector('[data-sidebar-session-menu]'); 228 if (!menu) return; 229 230 const trigger = menu.querySelector('.sidebar-session-trigger'); 231 232 // Toggle aria-expanded 233 if (trigger) { 234 trigger.addEventListener('click', () => { 235 const expanded = trigger.getAttribute('aria-expanded') === 'true'; 236 trigger.setAttribute('aria-expanded', expanded ? 'false' : 'true'); 237 }); 238 239 trigger.addEventListener('keydown', (e) => { 240 if (e.key === 'Escape') trigger.setAttribute('aria-expanded', 'false'); 241 }); 242 } 243 244 // Actions + nav 245 menu.addEventListener('click', async (e) => { 246 const nav = e.target.closest('[data-session-nav]'); 247 if (nav) { 248 const page = nav.getAttribute('data-session-nav'); 249 const mapping = NAV_MAP[page]; 250 251 // Dismiss dropdown immediately (override CSS :hover/:focus-within) 252 const dropdown = menu.querySelector('.sidebar-session-dropdown'); 253 if (dropdown) { 254 dropdown.style.opacity = '0'; 255 dropdown.style.visibility = 'hidden'; 256 dropdown.style.pointerEvents = 'none'; 257 menu.addEventListener('mouseleave', function handler() { 258 dropdown.style.removeProperty('opacity'); 259 dropdown.style.removeProperty('visibility'); 260 dropdown.style.removeProperty('pointer-events'); 261 menu.removeEventListener('mouseleave', handler); 262 }, { once: true }); 263 } 264 265 // Update URL for shareability â prefer path-based nav 266 if (mapping?.section && typeof window.navigateTo === 'function') { 267 window.navigateTo(mapping.section); 268 } else if (mapping?.hash) { 269 window.location.hash = mapping.hash; 270 } 271 272 window.dispatchEvent(new CustomEvent('haiphen:session:navigate', { 273 detail: { page, tab: mapping?.tab || null }, 274 })); 275 276 if (mapping?.section === 'GettingStarted') { 277 if (typeof window.navigateTo === 'function') window.navigateTo('GettingStarted'); 278 else if (typeof window.showSection === 'function') window.showSection('GettingStarted'); 279 } else if (mapping?.section === 'Profile') { 280 if (typeof window.HAIPHEN?.showProfile === 'function') { 281 await window.HAIPHEN.showProfile({ tab: mapping?.tab }); 282 } else if (typeof window.showSection === 'function') { 283 window.showSection('Profile'); 284 } 285 } 286 return; 287 } 288 289 const btn = e.target.closest('[data-session-action]'); 290 if (!btn) return; 291 292 const action = btn.getAttribute('data-session-action'); 293 294 if (action === 'switch-account') { 295 btn.disabled = true; 296 btn.textContent = 'Switchingâ¦'; 297 const loginUrl = loginHref('google'); 298 const u = new URL(LOGOUT_URL); 299 u.searchParams.set('to', loginUrl); 300 window.location.assign(u.toString()); 301 } 302 303 if (action === 'logout') { 304 btn.disabled = true; 305 btn.textContent = 'Logging outâ¦'; 306 const here = window.location.href; 307 const u = new URL(LOGOUT_URL); 308 u.searchParams.set('to', here); 309 window.location.assign(u.toString()); 310 } 311 }); 312 } 313 314 function renderLoggedIn(user) { 315 slot.innerHTML = sidebarMenuHtml(user); 316 slot.hidden = false; 317 wireSidebarMenu(); 318 checkEntitlementForAppLink(); 319 } 320 321 async function checkEntitlementForAppLink() { 322 var appSlot = mountEl.querySelector('#sidebar-app-link'); 323 if (!appSlot) return; 324 try { 325 var API_ORIGIN = window.HAIPHEN?.API_ORIGIN || 'https://api.haiphen.io'; 326 var r = await fetch(API_ORIGIN + '/v1/me', { 327 credentials: 'include', 328 cache: 'no-store', 329 headers: { 'content-type': 'application/json' }, 330 }); 331 if (!r.ok) return; 332 var data = await r.json(); 333 var plan = (data?.entitlements?.plan || data?.plan || data?.tier || '').toLowerCase(); 334 if (plan === 'pro' || plan === 'standard' || plan === 'professional' || plan === 'enterprise') { 335 appSlot.innerHTML = 336 '<a class="site-sidebar__link site-sidebar__link--app" href="https://app.haiphen.io" title="Open App">' + 337 '<span class="sidebar-icon sidebar-icon--app">' +
338 '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + 339 '<rect x="2" y="2" width="12" height="12" rx="2"/>' + 340 '<path d="M6 6l4 2-4 2z"/>' + 341 '</svg>' + 342 '</span>' + 343 '<span class="sidebar-label">Open App</span>' + 344 '</a>'; 345 appSlot.hidden = false; 346 347 // Hide the Download link â Pro/Enterprise users see "Open App" instead 348 var downloadLink = mountEl.querySelector('.site-sidebar__link--download'); 349 if (downloadLink) downloadLink.style.display = 'none'; 350 } 351 } catch (e) { 352 // Silently fail â link simply won't appear 353 } 354 } 355 356 // Minimal escaping 357 function escapeHtml(s) { 358 const str = String(s ?? ''); 359 return str 360 .replace(/&/g, '&') 361 .replace(/</g, '<') 362 .replace(/>/g, '>') 363 .replace(/"/g, '"') 364 .replace(/'/g, '''); 365 } 366 function escapeAttr(s) { 367 return escapeHtml(s); 368 } 369 370 let _sessionReceived = false; 371 372 function onSessionReady(e) { 373 if (_sessionReceived) return; 374 _sessionReceived = true; 375 const user = e?.detail?.user || null; 376 if (!user) return renderLoggedOut(); 377 return renderLoggedIn(user); 378 } 379 380 window.addEventListener('haiphen:session:ready', onSessionReady); 381 382 // Fallback: if header hasn't fired haiphen:session:ready within 3s, bootstrap independently 383 setTimeout(async () => { 384 if (_sessionReceived) return; 385 const me = await authMe(); 386 if (_sessionReceived) return; // event fired while we were fetching 387 _sessionReceived = true; 388 if (!me.ok || !me.user) return renderLoggedOut(); 389 return renderLoggedIn(me.user); 390 }, 3000); 391 } 392 393 function wireHamburger(mount) { 394 function attach() { 395 const btn = document.getElementById('sidebar-hamburger'); 396 if (!btn || btn.__sidebarWired) return; 397 btn.__sidebarWired = true; 398 399 // Create backdrop element if not present 400 if (!document.querySelector('.sidebar-mobile-backdrop')) { 401 const bd = document.createElement('div'); 402 bd.className = 'sidebar-mobile-backdrop'; 403 document.body.appendChild(bd); 404 bd.addEventListener('click', closeMobileSidebar); 405 } 406 407 btn.addEventListener('click', () => { 408 const open = document.body.classList.toggle('sidebar-mobile-open'); 409 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 410 btn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); 411 }); 412 413 // Close sidebar when a navigation link inside it is clicked 414 mount.addEventListener('click', (e) => { 415 if (e.target.closest('a[data-section]') || e.target.closest('[data-session-nav]')) { 416 closeMobileSidebar(); 417 } 418 }); 419 } 420 421 // Try immediately, or wait for header injection 422 if (document.getElementById('sidebar-hamburger')) { 423 attach(); 424 } else { 425 window.addEventListener('haiphen:header:ready', () => attach(), { once: true }); 426 } 427 } 428 429 function closeMobileSidebar() { 430 document.body.classList.remove('sidebar-mobile-open'); 431 const btn = document.getElementById('sidebar-hamburger'); 432 if (btn) { 433 btn.setAttribute('aria-expanded', 'false'); 434 btn.setAttribute('aria-label', 'Open menu'); 435 } 436 } 437 438 async function loadSidebar() { 439 const mount = qs(MOUNT_ID); 440 if (!mount) { 441 console.warn('[sidebar] mount missing'); 442 return; 443 } 444 445 if (qs(SIDEBAR_ID)) return; 446 447 await injectCssOnce('assets/base.css'); // ensure tokens exist 448 await injectCssOnce('components/sidebar/site-sidebar.css'); 449 450 const html = await fetchText('components/sidebar/site-sidebar.html'); 451 mount.innerHTML = `<div id="${SIDEBAR_ID}">${html}</div>`; 452 453 wire(mount); 454 wireHamburger(mount); 455 wireCollapseToggle(); 456 457 insertSidebarSessionCard(mount); 458 } 459 460 function updateSidebarLabels(lens) { 461 const sidebar = qs(SIDEBAR_ID); 462 if (!sidebar) return; 463 const links = sidebar.querySelectorAll('a[data-tech-label][data-finance-label]'); 464 links.forEach(function (a) { 465 var label = a.querySelector('.sidebar-label'); 466 var icon = a.querySelector('.sidebar-icon'); 467 if (!label) return; 468 if (lens === 'finance') { 469 label.textContent = a.getAttribute('data-finance-label') || label.textContent; 470 if (icon) icon.setAttribute('data-icon', a.getAttribute('data-finance-icon') || icon.getAttribute('data-icon')); 471 } else { 472 label.textContent = a.getAttribute('data-tech-label') || label.textContent; 473 if (icon) icon.setAttribute('data-icon', a.getAttribute('data-tech-icon') || icon.getAttribute('data-icon')); 474 } 475 }); 476 } 477 478 window.addEventListener('haiphen:lens', function (e) { 479 updateSidebarLabels(e?.detail?.lens || 'tech'); 480 }); 481 482 // Apply on load based on current lens 483 document.addEventListener('DOMContentLoaded', function () { 484 var lens = document.documentElement.getAttribute('data-lens') || 'tech'; 485 setTimeout(function () { updateSidebarLabels(lens); }, 500); 486 }); 487 488 // ââ User-driven collapse toggle ââââââââââââââââââââââââââââââââ 489 // Separate from the auto-collapse that showSection() applies. 490 // State persisted in localStorage so it survives page reloads. 491 // 492 // "sidebar-nohover" is added briefly after collapsing to prevent 493 // the hover-to-expand CSS from firing while the mouse is still 494 // on the sidebar. Removed on mouseleave. 495 496 var LS_COLLAPSE_KEY = 'haiphen.sidebar.userCollapsed'; 497 498 function isUserCollapsed() { 499 try { return localStorage.getItem(LS_COLLAPSE_KEY) === '1'; } catch(e) { return false; } 500 } 501 502 function setUserCollapsed(val) { 503 try { localStorage.setItem(LS_COLLAPSE_KEY, val ? '1' : '0'); } catch(e) {} 504 } 505 506 function wireCollapseToggle() { 507 var btn = document.getElementById('sidebar-collapse-btn'); 508 if (!btn) return; 509 510 var sidebar = btn.closest('.site-sidebar'); 511 512 btn.addEventListener('click', function (e) { 513 e.stopPropagation(); 514 var isCollapsed = document.body.classList.contains('sidebar-collapsed'); 515 516 if (isCollapsed) { 517 // Expand 518 setUserCollapsed(false); 519 document.body.classList.remove('sidebar-collapsed', 'sidebar-nohover'); 520 btn.setAttribute('aria-label', 'Collapse sidebar'); 521 btn.setAttribute('title', 'Collapse sidebar'); 522 } else {
523 // Collapse â suppress hover expansion until mouse leaves 524 setUserCollapsed(true); 525 document.body.classList.add('sidebar-collapsed', 'sidebar-nohover'); 526 btn.setAttribute('aria-label', 'Expand sidebar'); 527 btn.setAttribute('title', 'Expand sidebar'); 528 529 if (sidebar) { 530 sidebar.addEventListener('mouseleave', function handler() { 531 document.body.classList.remove('sidebar-nohover'); 532 sidebar.removeEventListener('mouseleave', handler); 533 }, { once: true }); 534 } 535 } 536 }); 537 538 // Sync aria-label with current state 539 if (document.body.classList.contains('sidebar-collapsed')) { 540 btn.setAttribute('aria-label', 'Expand sidebar'); 541 btn.setAttribute('title', 'Expand sidebar'); 542 } 543 } 544 545 // Apply persisted user preference on load 546 if (isUserCollapsed()) { 547 document.body.classList.add('sidebar-collapsed'); 548 } 549 550 window.HAIPHEN = window.HAIPHEN || {}; 551 window.HAIPHEN.loadSidebar = loadSidebar; 552 window.HAIPHEN.sidebarUserCollapsed = isUserCollapsed; 553})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.