/**
* 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">🔍</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
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) + ' · ' : '') + repo.commits + ' commits</div>' +
'</div>'
).join('') + '<div class="nav-search-footer"><a href="' + basePath + '/search?q=' + encodeURIComponent(q) + '&type=repo">View all →</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 →</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>
`
}