PageSourceSearch

https://haiphen.io/components/sidebar/site-sidebar.js

js haiphen.io collected 2026-10-01 12:01:46 UTC 22,007 bytes, 553 lines download raw bytes

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, '&amp;')
361        .replace(/</g, '&lt;')
362        .replace(/>/g, '&gt;')
363        .replace(/"/g, '&quot;')
364        .replace(/'/g, '&#39;');
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.