OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / nav-ui.js

252 lines · javascript ref:
/**
 * Shared Navigation Component
 *
 * Consistent nav bar across all pages with:
 * - Pear Git brand (links home)
 * - Search bar (repo + code search)
 * - Nav links (Repos, Groups, Admin, Deploy, Profile)
 * - Admin/Deploy links shown conditionally
 * - Identity badge handled by identity-ui.js
 */

/**
 * Render the shared navigation bar
 * @param {string} pathPrefix - URL path prefix (e.g., '/git' or '')
 * @param {string} activePage - Current page: 'repos'|'repo'|'groups'|'admin'|'deploy'|'profile'|'peer'
 * @returns {string} HTML string
 */
export function renderNav (pathPrefix = '', activePage = '') {
  const active = (page) => activePage === page ? ' nav-active' : ''

  return `
<nav id="pear-nav">
  <style>
    #pear-nav {
      position: fixed; top: 0; left: 0; right: 0; z-index: 9000;
      background: #0d1117; border-bottom: 1px solid #30363d;
      height: 56px; display: flex; align-items: center; justify-content: center;
      font-family: system-ui, -apple-system, sans-serif;
    }
    #pear-nav-inner {
      display: flex; align-items: center; gap: 16px;
      width: 100%; max-width: 1200px; padding: 0 16px;
    }
    body { padding-top: 72px; }
    .nav-brand {
      color: #c9d1d9; text-decoration: none; font-weight: 600; font-size: 16px;
      white-space: nowrap; flex-shrink: 0;
    }
    .nav-brand:hover { color: #ffffff; }
    .nav-search-wrap {
      flex: 1; max-width: 480px; position: relative;
    }
    .nav-search-box {
      display: flex; align-items: center; gap: 6px;
    }
    .nav-search-input {
      flex: 1; padding: 7px 12px 7px 32px;
      background: #161b22; border: 1px solid #30363d; border-radius: 6px;
      color: #c9d1d9; font-size: 14px; outline: none;
    }
    .nav-search-input:focus { border-color: #58a6ff; }
    .nav-search-icon {
      position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
      color: #484f58; font-size: 13px; pointer-events: none;
    }
    .nav-search-tabs {
      display: flex; gap: 2px; background: #21262d; padding: 2px; border-radius: 6px; flex-shrink: 0;
    }
    .nav-search-tab {
      padding: 4px 10px; background: transparent; border: none;
      color: #8b949e; cursor: pointer; border-radius: 4px; font-size: 12px;
    }
    .nav-search-tab.active { background: #30363d; color: #c9d1d9; }
    .nav-search-dropdown {
      position: absolute; top: 100%; left: 0; right: 0;
      background: #161b22; border: 1px solid #30363d; border-radius: 6px;
      margin-top: 4px; max-height: 400px; overflow-y: auto; z-index: 9100;
      display: none;
    }
    .nav-search-dropdown.visible { display: block; }
    .nav-search-result {
      padding: 10px 14px; border-bottom: 1px solid #21262d; cursor: pointer;
    }
    .nav-search-result:last-child { border-bottom: none; }
    .nav-search-result:hover { background: #0d1117; }
    .nav-search-result-name { color: #58a6ff; font-weight: 500; font-size: 13px; }
    .nav-search-result-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
    .nav-search-result-match {
      font-family: monospace; font-size: 11px; color: #8b949e;
      margin-top: 3px; padding: 3px 6px; background: #0d1117; border-radius: 3px;
    }
    .nav-search-result-match .hl { background: #5e4b00; color: #f0e68c; }
    .nav-search-loading, .nav-search-empty { padding: 16px; text-align: center; color: #8b949e; font-size: 13px; }
    .nav-search-footer { padding: 10px 14px; border-top: 1px solid #30363d; }
    .nav-search-footer a { color: #58a6ff; text-decoration: none; font-size: 12px; }
    .nav-links {
      display: flex; gap: 4px; align-items: center; flex-shrink: 0;
    }
    .nav-links a {
      color: #8b949e; text-decoration: none; padding: 6px 12px;
      border-radius: 6px; font-size: 13px; white-space: nowrap;
    }
    .nav-links a:hover { color: #c9d1d9; background: #161b22; }
    .nav-links a.nav-active { color: #c9d1d9; background: #161b22; border-bottom: 2px solid #58a6ff; }
  </style>
  <div id="pear-nav-inner">
    <a href="${pathPrefix}/" class="nav-brand">Pear Git</a>
    <div class="nav-search-wrap" id="nav-search-wrap">
      <div class="nav-search-box">
        <span class="nav-search-icon">&#x1F50D;</span>
        <input type="text" class="nav-search-input" id="nav-search-input"
          placeholder="Search..."
          oninput="window._navSearch.onInput(this.value)"
          onfocus="window._navSearch.onFocus()"
          onkeydown="window._navSearch.onKeydown(event)" />
        <div class="nav-search-tabs">
          <button class="nav-search-tab active" data-type="repo" onclick="window._navSearch.setType('repo')">Names</button>
          <button class="nav-search-tab" data-type="code" onclick="window._navSearch.setType('code')">Content</button>
        </div>
      </div>
      <div class="nav-search-dropdown" id="nav-search-dropdown">
        <div class="nav-search-loading" id="nav-search-loading" style="display:none;">Searching...</div>
        <div id="nav-search-results"></div>
      </div>
    </div>
    <div class="nav-links">
      <a href="${pathPrefix}/" class="${active('repos')}">Repos</a>
      <a href="${pathPrefix}/groups/" class="${active('groups')}">Groups</a>
      <a href="${pathPrefix}/admin/" id="nav-admin-link" style="display:none" class="${active('admin')}">Admin</a>
      <a href="${pathPrefix}/deploy/" id="nav-deploy-link" style="display:none" class="${active('deploy')}">Deploy</a>
      <a href="${pathPrefix}/peers/" id="nav-peers-link" style="display:none" class="${active('peer')}">Peers</a>
      <a href="${pathPrefix}/profile/" class="${active('profile')}">Profile</a>
    </div>
  </div>
</nav>
<script>
(function() {
  const basePath = '${pathPrefix}';

  // ========================================
  // Admin link visibility
  // ========================================
  async function checkNavAdmin() {
    try {
      const resp = await fetch(basePath + '/api/user/identities');
      if (!resp.ok) return;
      const data = await resp.json();
      const perms = data.permissions || {};
      const isAdmin = (perms.global || []).includes('admin') ||
        (perms.repos || []).some(r => r.permissions.includes('admin'));
      const hasWrite = isAdmin || (perms.global || []).includes('write');
      if (isAdmin) {
        const el = document.getElementById('nav-admin-link');
        if (el) el.style.display = '';
        const dp = document.getElementById('nav-deploy-link');
        if (dp) dp.style.display = '';
        const pr = document.getElementById('nav-peers-link');
        if (pr) pr.style.display = '';
      }
    } catch {}
  }
  // Check after identity is ready
  if (window.identityReady) {
    window.identityReady.then(() => checkNavAdmin());
  } else {
    setTimeout(checkNavAdmin, 1500);
  }

  // ========================================
  // Search
  // ========================================
  let searchType = 'repo';
  let debounce = null;

  function esc(text) {
    if (!text) return '';
    return String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }
  function hl(text, q) {
    if (!q) return text;
    const r = new RegExp('(' + q.replace(/[-\\\\/^$*+?.()|[\\]{}]/g, '\\\\$&') + ')', 'gi');
    return text.replace(r, '<span class="hl">$1</span>');
  }

  window._navSearch = {
    setType(type) {
      searchType = type;
      document.querySelectorAll('.nav-search-tab').forEach(t => t.classList.toggle('active', t.dataset.type === type));
      const q = document.getElementById('nav-search-input').value.trim();
      if (q.length >= 2) this._run(q);
    },
    onInput(val) {
      clearTimeout(debounce);
      if (val.trim().length < 2) { this._hide(); return; }
      debounce = setTimeout(() => this._run(val.trim()), 200);
    },
    onFocus() {
      const q = document.getElementById('nav-search-input').value.trim();
      if (q.length >= 2) document.getElementById('nav-search-dropdown').classList.add('visible');
    },
    onKeydown(e) {
      if (e.key === 'Escape') this._hide();
      else if (e.key === 'Enter') {
        const q = e.target.value.trim();
        if (q.length >= 2) window.location.href = basePath + '/search?q=' + encodeURIComponent(q) + '&type=' + searchType;
      }
    },
    _hide() { document.getElementById('nav-search-dropdown').classList.remove('visible'); },
    async _run(q) {
      const dd = document.getElementById('nav-search-dropdown');
      const res = document.getElementById('nav-search-results');
      const ld = document.getElementById('nav-search-loading');
      dd.classList.add('visible');
      ld.style.display = 'block';
      res.innerHTML = '';
      try {
        if (searchType === 'repo') {
          const r = await fetch(basePath + '/api/repos/search?q=' + encodeURIComponent(q) + '&limit=8');
          const d = await r.json();
          if (!d.repos || !d.repos.length) { res.innerHTML = '<div class="nav-search-empty">No repos found</div>'; return; }
          res.innerHTML = d.repos.map(repo =>
            '<div class="nav-search-result" onclick="window.location.href=\\'' + basePath + '/' + esc(repo.namespace || '_') + '/' + esc(repo.name) + '\\'">' +
              '<div class="nav-search-result-name">' + hl(esc(repo.name), q) + '</div>' +
              '<div class="nav-search-result-meta">' + (repo.category ? esc(repo.category) + ' &middot; ' : '') + repo.commits + ' commits</div>' +
            '</div>'
          ).join('') + '<div class="nav-search-footer"><a href="' + basePath + '/search?q=' + encodeURIComponent(q) + '&type=repo">View all &rarr;</a></div>';
        } else {
          const r = await fetch(basePath + '/api/search/code?q=' + encodeURIComponent(q) + '&limit=20&maxRepos=5');
          const d = await r.json();
          if (!d.results || !d.results.length) { res.innerHTML = '<div class="nav-search-empty">No code matches</div>'; return; }
          const matches = [];
          for (const result of d.results) {
            for (const m of result.matches.slice(0, 3)) {
              matches.push({ repo: result.repo, ...m });
              if (matches.length >= 8) break;
            }
            if (matches.length >= 8) break;
          }
          res.innerHTML = matches.map(m =>
            '<div class="nav-search-result" onclick="window.location.href=\\'' + basePath + '/local/' + esc(m.repo) + '/blob/' + encodeURIComponent(m.file) + '#L' + m.line + '\\'">' +
              '<div class="nav-search-result-name">' + esc(m.repo) + ' / ' + esc(m.file) + '</div>' +
              '<div class="nav-search-result-match">' + m.line + ': ' + hl(esc(m.content), q) + '</div>' +
            '</div>'
          ).join('') + '<div class="nav-search-footer"><a href="' + basePath + '/search?q=' + encodeURIComponent(q) + '&type=code">View all ' + (d.totalMatches || '') + ' matches &rarr;</a></div>';
        }
      } catch (err) {
        res.innerHTML = '<div class="nav-search-empty">Search failed</div>';
      } finally {
        ld.style.display = 'none';
      }
    }
  };

  // Close dropdown on outside click
  document.addEventListener('click', function(e) {
    if (!e.target.closest('#nav-search-wrap')) window._navSearch._hide();
  });
})();
</script>
  `
}