OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / repo-viewer-ui.js

1342 lines · javascript ref:
import { renderIssueModal } from './issue-modal-ui.js'
import { renderPage } from './page-layout.js'
import { escapeHtml } from './response.js'

/**
 * Render enhanced repository viewer page
 */
export function renderRepoViewer(repo, options = {}) {
  const { files = [], commits = [], readme = null, readmeIsMarkdown = false, docFiles = [], issues = [], branches = [], p2pInfo = null, labels = [], issueCounts = {}, isLinked = false, linkedWriters = [], pathPrefix = '', hasRelease = false, lastDeploy = null } = options

  // Generate P2P URLs if enabled
  // punch:// = identity-addressed P2P (ANProto Noise key, eventually Keet-derived)
  // git+pear:// = Hyperdrive-addressed git clone (requires driveKey)
  // pear:// = Hyperdrive content-addressed (Pear runtime viewer)
  const punchUrl = p2pInfo?.serverKey ? `punch://${p2pInfo.serverKey}/${repo.name}` : null
  const cloneUrl = p2pInfo?.driveKey ? `git+pear://${p2pInfo.driveKey}/${repo.name}` : null
  // pear-link URL points to the Hyperdrive root (the path is within the drive, not a repo name)
  // Format: pear://[<fork>.<length>.]<keyOrAlias>[.<dhash>]<path>
  const pearLinkUrl = p2pInfo?.driveKey ? `pear://${p2pInfo.driveKey}` : null

  const PAGE_CSS = `
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      padding: 20px;
    }
    .container { max-width: 1200px; margin: 0 auto; }

    .header {
      border-bottom: 1px solid #30363d;
      padding-bottom: 20px;
      margin-bottom: 20px;
    }
    h1 { font-size: 32px; margin-bottom: 10px; }
    .meta {
      color: #8b949e;
      display: flex;
      gap: 20px;
      font-size: 14px;
    }

    .tabs {
      display: flex;
      gap: 5px;
      border-bottom: 1px solid #30363d;
      margin-bottom: 20px;
    }
    .tab {
      padding: 10px 20px;
      background: transparent;
      border: none;
      color: #8b949e;
      cursor: pointer;
      border-radius: 6px 6px 0 0;
      font-size: 14px;
    }
    .tab.active {
      background: #161b22;
      border: 1px solid #30363d;
      border-bottom: none;
      color: #c9d1d9;
    }
    .tab:hover { color: #c9d1d9; }

    .tab-content { display: none; }
    .tab-content.active { display: block; }

    .file-list, .commit-list, .issue-list {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
    }

    .file-item, .commit-item, .issue-item {
      padding: 12px 16px;
      border-bottom: 1px solid #30363d;
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .file-item:last-child, .commit-item:last-child, .issue-item:last-child {
      border-bottom: none;
    }
    .file-item:hover, .commit-item:hover, .issue-item:hover {
      background: #0d1117;
      cursor: pointer;
    }

    .file-icon, .commit-icon { width: 20px; text-align: center; }
    .file-name { flex: 1; color: #58a6ff; }
    .file-size, .commit-date { color: #8b949e; font-size: 12px; }

    .commit-hash {
      font-family: monospace;
      background: #0d1117;
      padding: 2px 6px;
      border-radius: 3px;
      font-size: 12px;
      color: #58a6ff;
    }
    .commit-message { flex: 1; }
    .commit-author { color: #8b949e; font-size: 12px; }

    .readme {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 30px;
      line-height: 1.6;
    }
    .readme h1 { font-size: 24px; margin: 24px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
    .readme h2 { font-size: 20px; margin: 24px 0 16px; padding-bottom: 6px; border-bottom: 1px solid #30363d; }
    .readme h3 { font-size: 18px; margin: 20px 0 12px; }
    .readme h4 { font-size: 16px; margin: 16px 0 8px; }
    .readme p { margin: 12px 0; }
    .readme ul, .readme ol { margin: 12px 0; padding-left: 2em; }
    .readme li { margin: 4px 0; }
    .readme code {
      background: #0d1117;
      padding: 2px 6px;
      border-radius: 3px;
      font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
      font-size: 85%;
    }
    .readme pre {
      background: #0d1117;
      padding: 16px;
      border-radius: 6px;
      overflow-x: auto;
      margin: 16px 0;
    }
    .readme pre code {
      background: none;
      padding: 0;
      font-size: 13px;
      line-height: 1.45;
    }
    .readme table {
      border-collapse: collapse;
      margin: 16px 0;
      width: 100%;
    }
    .readme th, .readme td {
      border: 1px solid #30363d;
      padding: 8px 12px;
      text-align: left;
    }
    .readme th {
      background: #0d1117;
      font-weight: 600;
    }
    .readme blockquote {
      border-left: 3px solid #30363d;
      margin: 16px 0;
      padding: 4px 16px;
      color: #8b949e;
    }
    .readme a { color: #58a6ff; text-decoration: none; }
    .readme a:hover { text-decoration: underline; }
    .readme img { max-width: 100%; }
    .readme hr { border: none; border-top: 1px solid #30363d; margin: 24px 0; }
    .readme strong { font-weight: 600; }

    .docs-layout {
      display: flex;
      gap: 20px;
      min-height: 400px;
    }
    .docs-sidebar {
      width: 250px;
      flex-shrink: 0;
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 12px;
      overflow-y: auto;
      max-height: 80vh;
    }
    .docs-sidebar h3 {
      font-size: 13px;
      color: #8b949e;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      margin-bottom: 10px;
      padding-bottom: 8px;
      border-bottom: 1px solid #30363d;
    }
    .doc-file-item {
      padding: 8px 10px;
      cursor: pointer;
      border-radius: 4px;
      font-size: 13px;
      color: #c9d1d9;
      display: flex;
      align-items: center;
      gap: 8px;
      transition: background 0.15s;
    }
    .doc-file-item:hover {
      background: #21262d;
    }
    .doc-file-item.active {
      background: #1f6feb;
      color: white;
    }
    .doc-file-item .doc-icon {
      font-size: 14px;
      flex-shrink: 0;
    }
    .doc-file-item .doc-name {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .doc-file-item .doc-dir {
      color: #8b949e;
      font-size: 11px;
    }
    .docs-content {
      flex: 1;
      min-width: 0;
    }
    .docs-content .readme {
      min-height: 300px;
    }
    .docs-empty {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 300px;
      color: #8b949e;
      font-size: 14px;
    }

    .clone-box {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 20px;
      margin-bottom: 20px;
    }
    .clone-box h3 { margin-bottom: 15px; font-size: 16px; }
    .clone-url {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }
    .clone-url input {
      flex: 1;
      background: #0d1117;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 8px 12px;
      color: #c9d1d9;
      font-family: monospace;
      font-size: 12px;
    }
    .clone-url button {
      background: #238636;
      color: white;
      border: none;
      padding: 8px 16px;
      border-radius: 6px;
      cursor: pointer;
      font-size: 12px;
    }
    .clone-url button:hover { background: #2ea043; }

    .empty-state {
      text-align: center;
      padding: 60px 20px;
      color: #8b949e;
    }
    .empty-state-icon { font-size: 48px; margin-bottom: 10px; }

    .issue-item {
      display: flex;
      align-items: flex-start;
      gap: 10px;
    }
    .issue-number {
      background: #0d1117;
      padding: 4px 8px;
      border-radius: 3px;
      font-family: monospace;
      font-size: 12px;
      color: #8b949e;
    }
    .issue-title { flex: 1; font-weight: 500; }
    .issue-comments {
      color: #8b949e;
      font-size: 12px;
      display: flex;
      align-items: center;
      gap: 4px;
      min-width: 60px;
      margin-right: 10px;
    }
    .issue-status {
      padding: 2px 8px;
      border-radius: 12px;
      font-size: 11px;
      font-weight: 600;
    }
    .issue-status.open { background: #238636; color: white; }
    .issue-status.closed { background: #8250df; color: white; }

    button.primary {
      background: #238636;
      color: white;
      border: none;
      padding: 8px 16px;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
      margin-bottom: 20px;
    }
    button.primary:hover { background: #2ea043; }

    .back-link {
      color: #58a6ff;
      text-decoration: none;
      margin-bottom: 20px;
      display: inline-block;
    }
    .back-link:hover { text-decoration: underline; }

    .branch-selector {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 15px;
      margin-bottom: 20px;
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .branch-selector label {
      color: #8b949e;
      font-size: 14px;
    }
    .branch-selector select {
      background: #0d1117;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 8px 12px;
      color: #c9d1d9;
      font-size: 14px;
      cursor: pointer;
      min-width: 200px;
    }
    .branch-selector select:hover {
      border-color: #58a6ff;
    }
    .branch-icon {
      font-size: 16px;
    }

    .peers-table {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      overflow: hidden;
    }
    .peers-header {
      display: grid;
      grid-template-columns: 1fr 100px 180px;
      gap: 15px;
      padding: 12px 16px;
      background: #0d1117;
      border-bottom: 1px solid #30363d;
      font-weight: 600;
      font-size: 12px;
      text-transform: uppercase;
      color: #8b949e;
    }
    .peers-row {
      display: grid;
      grid-template-columns: 1fr 100px 180px;
      gap: 15px;
      padding: 12px 16px;
      border-bottom: 1px solid #30363d;
      align-items: center;
    }
    .peers-row:last-child { border-bottom: none; }
    .peers-row:hover { background: #0d1117; }
    .peer-user { font-weight: 500; }
    .peer-key {
      font-family: monospace;
      font-size: 11px;
      color: #8b949e;
    }
    .peer-role {
      display: inline-block;
      padding: 2px 8px;
      border-radius: 12px;
      font-size: 11px;
      font-weight: 600;
    }
    .peer-role.owner { background: #238636; color: white; }
    .peer-role.writer { background: #1f6feb; color: white; }
    .peer-role.mirror { background: #6e7681; color: white; }
    .invite-box {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 16px;
      margin-top: 20px;
    }
    .invite-box h3 { margin-bottom: 12px; font-size: 16px; }
    .invite-result {
      margin-top: 12px;
      padding: 12px;
      background: #0d1117;
      border: 1px solid #30363d;
      border-radius: 6px;
      font-family: monospace;
      font-size: 12px;
      word-break: break-all;
      display: none;
    }
    .invite-result.visible { display: block; }
  `

  const content = `
    <div class="header">
      <h1>📦 ${escapeHtml(repo.name)}${isLinked ? ' <span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 13px; font-weight: 600; background: #1f6feb; color: white; vertical-align: middle; margin-left: 8px;">Linked</span>' : ''}</h1>
      <div class="meta">
        <span>🌿 ${escapeHtml(repo.selectedBranch || 'HEAD')}</span>
        <span>📝 ${commits.length} commits shown</span>
        <span>📅 ${commits[0] ? new Date(commits[0].timestamp * 1000).toLocaleDateString() : 'N/A'}</span>
        ${isLinked ? `<span style="color: #58a6ff;">🔗 ${linkedWriters.length} peer${linkedWriters.length !== 1 ? 's' : ''}</span>` : ''}
      </div>
    </div>

    ${branches.length > 0 ? `
    <div class="branch-selector">
      <span class="branch-icon">🌿</span>
      <label for="branch-select">Branch:</label>
      <select id="branch-select" onchange="switchBranch(this.value)">
        ${branches.map(branch => {
          const branchName = branch.name.replace(/^remotes\/[^/]+\//, '').replace(/^\*\s*/, '').trim()
          const isSelected = repo.selectedBranch === branchName ||
                           (repo.selectedBranch === 'HEAD' && branch.current) ||
                           (!repo.selectedBranch && branch.current)
          return `<option value="${escapeHtml(branchName)}" ${isSelected ? 'selected' : ''}>${escapeHtml(branchName)}</option>`
        }).join('')}
      </select>
      <span style="color: #8b949e; font-size: 12px;">${branches.length} branch${branches.length !== 1 ? 'es' : ''}</span>
    </div>
    ` : ''}

    <div class="clone-box">
      <h3>Clone this repository</h3>
      ${punchUrl ? `
      <div class="clone-url">
        <input readonly value="git clone ${escapeHtml(punchUrl)}" id="clone-punch" title="P2P clone via server identity">
        <button onclick="copyClone('clone-punch')">Copy</button>
      </div>
      ${cloneUrl ? `
      <p style="color: #8b949e; font-size: 11px; margin-top: 8px; margin-bottom: 4px;">Hyperdrive (content-addressed):</p>
      <div class="clone-url">
        <input readonly value="${escapeHtml(cloneUrl)}" id="clone-p2p" title="P2P git clone via Hyperdrive">
        <button onclick="copyClone('clone-p2p')">Copy</button>
      </div>
      ` : ''}
      <p style="color: #8b949e; font-size: 11px; margin-top: 8px; margin-bottom: 4px;">HTTP${p2pInfo?.externalUrl ? 'S' : ' (LAN fallback)'}:</p>
      <div class="clone-url">
        <input readonly value="git clone ${p2pInfo?.externalUrl ? escapeHtml(p2pInfo.externalUrl) + '/' + escapeHtml(repo.username || 'fleeky') + '/' + escapeHtml(repo.name) + '.git' : 'http://' + escapeHtml(p2pInfo?.httpHost || 'localhost') + ':' + escapeHtml(String(p2pInfo?.httpPort || '5005')) + escapeHtml(p2pInfo?.pathPrefix || '') + '/' + escapeHtml(repo.name) + '.git'}" id="clone-http" title="${p2pInfo?.externalUrl ? 'HTTPS clone' : 'HTTP clone (LAN only)'}">
        <button onclick="copyClone('clone-http')">Copy</button>
      </div>
      <p style="color: #8b949e; font-size: 11px; margin-top: 6px;">
        Add petname: <code style="background: #161b22; padding: 2px 4px; border-radius: 3px;">pgh remote add myname ${p2pInfo?.serverKey?.slice(0, 16)}...</code>
      </p>
      ` : (repo.punchUrl ? `
      <div class="clone-url">
        <input readonly value="git clone ${escapeHtml(repo.punchUrl)}" id="clone-p2p">
        <button onclick="copyClone('clone-p2p')">Copy</button>
      </div>
      ` : `
      <div style="margin-top: 10px; padding: 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px;">
        <p style="color: #8b949e; font-size: 13px;">P2P not enabled — start server with --network</p>
      </div>
      `)}
    </div>

    <!-- Repo Linking Controls (admin only) -->
    <div id="linking-controls" style="display: none; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px; margin-bottom: 20px;">
      <div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
        ${!isLinked ? `
          <button class="primary" onclick="linkRepo('${escapeHtml(repo.name)}')" id="link-repo-btn" style="margin-bottom: 0;">🔗 Link Repo</button>
          <span style="color: #8b949e; font-size: 13px;">Enable multi-server collaboration via Autobase</span>
        ` : `
          <span style="color: #58a6ff; font-size: 14px; font-weight: 500;">🔗 Linked</span>
          <button style="background: #da3633; color: white; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;" onclick="unlinkRepo('${escapeHtml(repo.name)}')">Unlink</button>
        `}
        <div style="flex: 1;"></div>
        <button style="background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;" onclick="toggleJoinForm()">Join with Invite</button>
      </div>
      <!-- Join form -->
      <div id="join-form" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d;">
        <div style="display: flex; gap: 8px; align-items: center;">
          <input type="text" id="join-invite-hex" placeholder="Paste invite hex from another server..." style="flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9; font-family: monospace; font-size: 12px;">
          <button class="primary" onclick="joinWithInvite('${escapeHtml(repo.name)}')" style="margin-bottom: 0;">Join</button>
        </div>
      </div>
    </div>

    <div class="tabs">
      <button class="tab active" onclick="switchTab('files')">📁 Files</button>
      <button class="tab" onclick="switchTab('commits')">📝 Commits</button>
      <button class="tab" onclick="switchTab('issues')">🐛 Issues (${issues.length})</button>
      ${docFiles.length > 0 ? `<button class="tab" onclick="switchTab('docs')">📖 Docs (${docFiles.length})</button>` : ''}
      ${isLinked ? `<button class="tab" onclick="switchTab('peers')">🔗 Peers (${linkedWriters.length})</button>` : ''}
      ${hasRelease ? `<button class="tab" onclick="switchTab('releases')">📦 Releases</button>` : ''}
    </div>

    <div id="tab-files" class="tab-content active">
      ${renderFilesTab(files, readme, readmeIsMarkdown)}
    </div>

    <div id="tab-commits" class="tab-content">
      ${renderCommitsTab(commits)}
    </div>

    <div id="tab-issues" class="tab-content">
      ${renderIssuesTab(issues, repo.name, labels, issueCounts)}
    </div>

    ${docFiles.length > 0 ? `
    <div id="tab-docs" class="tab-content">
      ${renderDocsTab(docFiles, repo.name)}
    </div>
    ` : ''}

    ${isLinked ? `
    <div id="tab-peers" class="tab-content">
      ${renderPeersTab(linkedWriters, repo.name)}
    </div>
    ` : ''}

    ${hasRelease ? `
    <div id="tab-releases" class="tab-content">
      ${renderReleasesTab(repo, lastDeploy, pathPrefix)}
    </div>
    ` : ''}
  </div>

  `

  const scripts = `
    function getBasePath() {
      return window.__PATH_PREFIX__ || '';
    }

    // Show linking controls for admin users (nav handles admin link + user badge)
    async function showLinkingControls() {
      if (window.identityReady) {
        await window.identityReady;
      }
      try {
        const response = await window.fetch(getBasePath() + '/api/user/identities');
        if (!response.ok) return;
        const data = await response.json();
        const hasAdmin = data.permissions.global.includes('admin') ||
                         data.permissions.repos.some(r => r.permissions.includes('admin'));
        if (hasAdmin) {
          const linkCtrl = document.getElementById('linking-controls');
          if (linkCtrl) linkCtrl.style.display = 'block';
        }
      } catch (err) {
        console.error('Failed to check admin for linking controls:', err);
      }
    }
    showLinkingControls();

    function switchTab(tab) {
      document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
      document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));

      event.target.classList.add('active');
      document.getElementById('tab-' + tab).classList.add('active');

      // Auto-load docs overview (or first doc) when docs tab is opened
      if (tab === 'docs' && !window.__docsLoaded) {
        window.__docsLoaded = true;
        var overview = document.querySelector('#tab-docs .doc-overview');
        var firstDoc = overview || document.querySelector('#tab-docs .doc-file-item');
        if (firstDoc) firstDoc.click();
      }
    }

    function copyClone(id) {
      const input = document.getElementById(id);
      const text = input.value;
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(text);
      } else {
        input.select();
        document.execCommand('copy');
      }
      event.target.textContent = 'Copied!';
      setTimeout(() => {
        event.target.textContent = 'Copy';
      }, 2000);
    }

    function switchBranch(branchName) {
      // Reload page with branch parameter
      const url = new URL(window.location);
      url.searchParams.set('branch', branchName);
      window.location.href = url.toString();
    }

    function viewIssue(number) {
      window.location.href = window.location.pathname + '/issues/' + number;
    }

    function viewFile(filePath, isDirectory) {
      const prefix = isDirectory ? '/tree/' : '/blob/';
      window.location.href = window.location.pathname + prefix + filePath;
    }

    async function loadDoc(el, filePath, repoName) {
      // Update active state in sidebar
      document.querySelectorAll('.doc-file-item').forEach(function(item) { item.classList.remove('active'); });
      el.classList.add('active');

      var placeholder = document.getElementById('docs-placeholder');
      var rendered = document.getElementById('docs-rendered');
      if (placeholder) placeholder.style.display = 'none';
      rendered.style.display = 'block';
      rendered.innerHTML = '<div style="color: #8b949e; padding: 20px;">Loading...</div>';

      try {
        var response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/raw?path=' + encodeURIComponent(filePath));
        if (!response.ok) throw new Error('Failed to load file');
        var text = await response.text();

        if (filePath.toLowerCase().endsWith('.md') && typeof marked !== 'undefined') {
          marked.setOptions({ breaks: false, gfm: true, headerIds: true });
          var parsed = marked.parse(text);
          // Sanitize
          var scriptRe = new RegExp('<scr' + 'ipt[\\\\s\\\\S]*?<\\\\/scr' + 'ipt>', 'gi');
          parsed = parsed.replace(scriptRe, '');
          parsed = parsed.replace(new RegExp('<ifr' + 'ame[\\\\s\\\\S]*?<\\\\/ifr' + 'ame>', 'gi'), '');
          parsed = parsed.replace(new RegExp('<ifr' + 'ame[^>]*/?>', 'gi'), '');
          parsed = parsed.replace(/\\bon\\w+\\s*=/gi, 'data-removed=');
          parsed = parsed.replace(/javascript\\s*:/gi, '');
          rendered.innerHTML = '<h3 style="margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; color: #8b949e; font-size: 13px;">' + filePath.replace(/</g, '&lt;') + '</h3>' + parsed;
        } else {
          rendered.innerHTML = '<h3 style="margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #30363d; color: #8b949e; font-size: 13px;">' + filePath.replace(/</g, '&lt;') + '</h3><pre style="white-space: pre-wrap;">' + text.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>';
        }
      } catch (err) {
        rendered.innerHTML = '<div style="color: #da3633; padding: 20px;">Failed to load document: ' + err.message + '</div>';
      }
    }

    async function createInvite(repoName) {
      const btn = event.target;
      const resultEl = document.getElementById('invite-result');
      btn.disabled = true;
      btn.textContent = 'Creating...';

      try {
        const response = await window.fetch(getBasePath() + \`/api/repos/\${encodeURIComponent(repoName)}/invite\`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ role: 'writer' })
        });

        const data = await response.json();

        if (response.ok && data.invite) {
          resultEl.textContent = data.invite;
          resultEl.classList.add('visible');
          const copyBtn = document.getElementById('copy-invite-btn');
          if (copyBtn) copyBtn.style.display = 'inline-block';
        } else {
          alert('Failed to create invite: ' + (data.error || 'Unknown error'));
        }
      } catch (err) {
        alert('Error: ' + err.message);
      } finally {
        btn.disabled = false;
        btn.textContent = '+ Create Invite';
      }
    }

    function copyInvite() {
      const el = document.getElementById('invite-result');
      navigator.clipboard.writeText(el.textContent).then(() => {
        const btn = event.target;
        btn.textContent = '✓ Copied!';
        setTimeout(() => { btn.textContent = '📋 Copy'; }, 2000);
      });
    }

    function toggleJoinForm() {
      const form = document.getElementById('join-form');
      form.style.display = form.style.display === 'none' ? 'block' : 'none';
    }

    async function linkRepo(repoName) {
      if (!confirm('Link this repo for multi-server collaboration?')) return;
      const btn = document.getElementById('link-repo-btn');
      btn.disabled = true;
      btn.textContent = 'Linking...';
      try {
        const response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/link', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' }
        });
        const data = await response.json();
        if (response.ok) {
          alert('Repo linked! Invite: ' + (data.invite || '(none)'));
          window.location.reload();
        } else {
          alert('Failed: ' + (data.error || 'Unknown error'));
          btn.disabled = false;
          btn.textContent = '🔗 Link Repo';
        }
      } catch (err) {
        alert('Error: ' + err.message);
        btn.disabled = false;
        btn.textContent = '🔗 Link Repo';
      }
    }

    async function unlinkRepo(repoName) {
      if (!confirm('Unlink this repo? This removes the Autobase overlay.')) return;
      try {
        const response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/unlink', {
          method: 'DELETE'
        });
        if (response.ok) {
          alert('Repo unlinked');
          window.location.reload();
        } else {
          const data = await response.json();
          alert('Failed: ' + (data.error || 'Unknown error'));
        }
      } catch (err) {
        alert('Error: ' + err.message);
      }
    }

    async function joinWithInvite(repoName) {
      const inviteHex = document.getElementById('join-invite-hex').value.trim();
      if (!inviteHex) { alert('Paste an invite hex first'); return; }
      try {
        const response = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/join', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ invite: inviteHex })
        });
        const data = await response.json();
        if (response.ok) {
          alert('Joined! Bootstrap key: ' + (data.bootstrapKey || '(unknown)'));
          window.location.reload();
        } else {
          alert('Failed: ' + (data.error || 'Unknown error'));
        }
      } catch (err) {
        alert('Error: ' + err.message);
      }
    }

    async function migrateToP2P() {
      if (!confirm('This will enable P2P cloning for this repository. Continue?')) {
        return;
      }

      const repoName = window.location.pathname.split('/').filter(Boolean).pop();

      try {
        event.target.disabled = true;
        event.target.textContent = 'Migrating...';

        const response = await window.fetch(getBasePath() + \`/api/repos/\${repoName}/migrate\`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            strategy: 'hyperdrive',
            options: {}
          })
        });

        const result = await response.json();

        if (response.ok) {
          alert(\`✓ P2P enabled!\\n\\nClone with:\\ngit clone \${result.punchUrl}\`);
          window.location.reload();
        } else {
          alert(\`Error: \${result.error}\`);
          event.target.disabled = false;
          event.target.textContent = 'Enable P2P Clone';
        }
      } catch (err) {
        alert(\`Error: \${err.message}\`);
        event.target.disabled = false;
        event.target.textContent = 'Enable P2P Clone';
      }
    }
  `

  // Issue modal needs to be in the content, not after scripts
  const fullContent = content + '\n' + renderIssueModal()

  return renderPage(escapeHtml(repo.name), fullContent, {
    pathPrefix,
    activePage: 'repo',
    css: PAGE_CSS,
    scripts,
    head: `<script src="${pathPrefix}/vendor/marked.min.js"></script>`
  })
}

function renderFilesTab(files, readme, readmeIsMarkdown) {
  let content = ''

  // Show README if present
  if (readme) {
    if (readmeIsMarkdown) {
      // Render markdown client-side with marked.js
      const escapedMd = readme
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
      content += `
        <div class="readme" id="readme-content">
          <pre style="white-space: pre-wrap;">Loading...</pre>
        </div>
        <script>
          (function() {
            const raw = ${JSON.stringify(readme).replace(/<\//g, '<\\/')};
            if (typeof marked !== 'undefined') {
              marked.setOptions({
                breaks: false,
                gfm: true,
                headerIds: true
              });
              // Sanitize HTML output to prevent XSS from malicious READMEs
              var parsed = marked.parse(raw);
              // Note: avoid literal close-script in regex to not break HTML parser
              var scriptRe = new RegExp('<scr' + 'ipt[\\\\s\\\\S]*?<\\\\/scr' + 'ipt>', 'gi');
              parsed = parsed.replace(scriptRe, '');
              var iframeRe = new RegExp('<ifr' + 'ame[\\\\s\\\\S]*?<\\\\/ifr' + 'ame>', 'gi');
              parsed = parsed.replace(iframeRe, '');
              parsed = parsed.replace(new RegExp('<ifr' + 'ame[^>]*/?>', 'gi'), '');
              parsed = parsed.replace(new RegExp('<object[\\\\s\\\\S]*?</object>', 'gi'), '');
              parsed = parsed.replace(new RegExp('<embed[^>]*/?>','gi'), '');
              parsed = parsed.replace(new RegExp('<style[\\\\s\\\\S]*?</style>', 'gi'), '');
              parsed = parsed.replace(/\\bon\\w+\\s*=/gi, 'data-removed=');
              parsed = parsed.replace(/javascript\\s*:/gi, '');
              parsed = parsed.replace(/vbscript\\s*:/gi, '');
              parsed = parsed.replace(/data\\s*:[^image]/gi, '');
              document.getElementById('readme-content').innerHTML = parsed;
            } else {
              document.getElementById('readme-content').innerHTML = '<pre style="white-space: pre-wrap;">' + raw.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>';
            }
          })();
        </script>
        <div style="height: 30px;"></div>
      `
    } else {
      content += `
        <div class="readme">
          <pre style="white-space: pre-wrap;">${escapeHtml(readme)}</pre>
        </div>
        <div style="height: 30px;"></div>
      `
    }
  }

  // Show files list
  if (files.length === 0) {
    content += `
      <div class="empty-state">
        <div class="empty-state-icon">📂</div>
        <div>No files available yet</div>
        <div style="font-size: 12px; margin-top: 10px;">Files will appear here once the git repository is fully imported</div>
      </div>
    `
  } else {
    content += `
      <div>
        <h2 style="margin-bottom: 15px;">📁 Files</h2>
        <div class="file-list">
          ${files.map(file => {
            let icon = '📄'
            if (file.type === 'directory') icon = '📁'
            else if (file.type === 'submodule') icon = '📦'

            const isNavigable = file.type === 'directory' || file.type === 'submodule'
            const onclick = `viewFile(${escapeHtml(JSON.stringify(file.path))}, ${isNavigable})`

            return `
            <div class="file-item" onclick="${onclick}">
              <div class="file-icon">${icon}</div>
              <div class="file-name">${escapeHtml(file.name)}</div>
              <div class="file-size">${file.size ? formatBytes(file.size) : file.type === 'submodule' ? 'submodule' : ''}</div>
            </div>
            `
          }).join('')}
        </div>
      </div>
    `
  }

  return content
}

function renderDocsTab(docFiles, repoName) {
  if (docFiles.length === 0) {
    return `
      <div class="empty-state">
        <div class="empty-state-icon">📖</div>
        <div>No documentation files found</div>
      </div>
    `
  }

  // Human-readable name from filename
  function prettyName(filename) {
    return filename
      .replace(/\.md$/i, '')
      .replace(/\.txt$/i, '')
      .replace(/[_-]/g, ' ')
      .replace(/\b\w/g, c => c.toUpperCase())
  }

  // Human-readable directory labels
  const dirLabels = {
    'docs': 'Reference',
    'docs/architecture': 'Architecture',
    'docs/phases': 'Phase History'
  }

  // Group files: root .md, docs/ root, docs/architecture/, docs/phases/
  const rootFiles = docFiles.filter(f => !f.path.includes('/'))
  const dirFiles = {}
  docFiles.filter(f => f.path.includes('/')).forEach(f => {
    // Get the directory path (e.g. "docs" or "docs/architecture")
    const parts = f.path.split('/')
    parts.pop() // remove filename
    const dir = parts.join('/')
    if (!dirFiles[dir]) dirFiles[dir] = []
    dirFiles[dir].push(f)
  })

  // Find docs/README.md for auto-load
  const docsReadme = docFiles.find(f => f.path.toLowerCase() === 'docs/readme.md')
  const hasDocsReadme = !!docsReadme

  // Sort directory entries: docs/ first, then alphabetically
  const sortedDirs = Object.keys(dirFiles).sort((a, b) => {
    if (a === 'docs') return -1
    if (b === 'docs') return 1
    return a.localeCompare(b)
  })

  // Within each group, put README.md first, then sort alphabetically
  for (const dir of sortedDirs) {
    dirFiles[dir].sort((a, b) => {
      if (a.name.toLowerCase() === 'readme.md') return -1
      if (b.name.toLowerCase() === 'readme.md') return 1
      return a.name.localeCompare(b.name)
    })
  }

  return `
    <div class="docs-layout">
      <div class="docs-sidebar">
        <h3>Documentation</h3>
        ${rootFiles.length > 0 ? `
          ${rootFiles.map(f => `
            <div class="doc-file-item" onclick="loadDoc(this, ${escapeHtml(JSON.stringify(f.path))}, '${escapeHtml(repoName)}')">
              <span class="doc-icon">${f.name.toLowerCase().endsWith('.md') ? '📄' : '📋'}</span>
              <span class="doc-name">${escapeHtml(prettyName(f.name))}</span>
            </div>
          `).join('')}
        ` : ''}
        ${sortedDirs.map(dir => `
          <div style="margin-top: 12px;">
            <div style="font-size: 12px; color: #8b949e; padding: 4px 10px; font-weight: 500;">📁 ${escapeHtml(dirLabels[dir] || dir.split('/').pop())}</div>
            ${dirFiles[dir].map(f => `
              <div class="doc-file-item${hasDocsReadme && f.path.toLowerCase() === 'docs/readme.md' ? ' doc-overview' : ''}" onclick="loadDoc(this, ${escapeHtml(JSON.stringify(f.path))}, '${escapeHtml(repoName)}')">
                <span class="doc-icon">${f.name.toLowerCase() === 'readme.md' ? '📖' : f.name.toLowerCase().endsWith('.md') ? '📄' : '📋'}</span>
                <span class="doc-name">${f.name.toLowerCase() === 'readme.md' ? 'Overview' : escapeHtml(prettyName(f.name))}</span>
              </div>
            `).join('')}
          </div>
        `).join('')}
      </div>
      <div class="docs-content">
        <div class="docs-empty" id="docs-placeholder">
          Select a document from the sidebar to view it
        </div>
        <div class="readme" id="docs-rendered" style="display: none;"></div>
      </div>
    </div>
  `
}

function formatBytes(bytes) {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}

function renderCommitsTab(commits) {
  if (commits.length === 0) {
    return `
      <div class="empty-state">
        <div class="empty-state-icon">📝</div>
        <div>No commits available yet</div>
      </div>
    `
  }

  return `
    <div class="commit-list">
      ${commits.map(commit => `
        <div class="commit-item">
          <div class="commit-hash">${commit.shortHash || commit.hash.substring(0, 7)}</div>
          <div class="commit-message">${escapeHtml(commit.subject || commit.message)}</div>
          <div class="commit-author">${escapeHtml(commit.author)}</div>
          <div class="commit-date">${new Date(commit.timestamp * 1000).toLocaleDateString()}</div>
        </div>
      `).join('')}
    </div>
  `
}

function renderIssuesTab(issues, repoName, labels = [], issueCounts = {}) {
  // Create label map for quick lookup
  const labelMap = {}
  labels.forEach(l => { labelMap[l.name] = l })

  return `
    <div style="display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; align-items: center;">
      <button class="primary" onclick="openIssueModal()" style="margin-bottom: 0;">+ New Issue</button>

      <!-- Status filter -->
      <div style="display: flex; gap: 5px; background: #161b22; padding: 4px; border-radius: 6px; border: 1px solid #30363d;">
        <button class="issue-filter-btn active" data-status="open" onclick="filterByStatus('open')">
          🟢 Open ${issueCounts.open || 0}
        </button>
        <button class="issue-filter-btn" data-status="closed" onclick="filterByStatus('closed')">
          🟣 Closed ${issueCounts.closed || 0}
        </button>
        <button class="issue-filter-btn" data-status="all" onclick="filterByStatus('all')">
          All ${issueCounts.total || 0}
        </button>
      </div>

      <!-- Search -->
      <div style="flex: 1; min-width: 200px;">
        <input type="text" id="issue-search" placeholder="🔍 Search issues..."
          style="width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9;"
          oninput="searchIssues(this.value)">
      </div>

      <!-- Sort -->
      <select id="issue-sort" onchange="sortIssues(this.value)"
        style="background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9;">
        <option value="newest">Newest</option>
        <option value="oldest">Oldest</option>
        <option value="recently-updated">Recently updated</option>
      </select>
    </div>

    ${labels.length > 0 ? `
    <!-- Label filter -->
    <div style="margin-bottom: 20px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 12px;">
      <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
        <span style="color: #8b949e; font-size: 12px;">Labels:</span>
        ${labels.map(label => {
          const styles = getLabelStyles(label.color)
          return `
            <button class="label-filter-btn" data-label="${escapeHtml(label.name)}"
              onclick="toggleLabelFilter(${escapeHtml(JSON.stringify(label.name))})"
              style="background: transparent; border: 1px solid ${escapeHtml(label.color)}; color: ${escapeHtml(label.color)}; padding: 2px 8px; border-radius: 12px; font-size: 11px; cursor: pointer;">
              ${escapeHtml(label.displayName || label.name)}
            </button>
          `
        }).join('')}
        <button onclick="clearLabelFilters()" style="background: transparent; border: none; color: #8b949e; font-size: 11px; cursor: pointer; padding: 2px 8px;">
          Clear all
        </button>
      </div>
    </div>
    ` : ''}

    <div id="issues-container">
    ${issues.length === 0 ? `
      <div class="empty-state">
        <div class="empty-state-icon">🐛</div>
        <div>No issues yet</div>
        <div style="font-size: 12px; margin-top: 10px;">Click "New Issue" to create the first one</div>
      </div>
    ` : `
      <div class="issue-list">
        ${issues.map(issue => `
          <div class="issue-item" data-status="${escapeHtml(issue.status)}" data-labels="${escapeHtml((issue.labels || []).join(','))}" onclick="viewIssue(${issue.number})">
            <div style="display: flex; align-items: center; gap: 10px; flex: 1;">
              <div class="issue-number">#${issue.number}</div>
              <div style="flex: 1;">
                <div class="issue-title">${escapeHtml(issue.title)}</div>
                ${(issue.labels && issue.labels.length > 0) ? `
                  <div style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
                    ${issue.labels.map(labelName => {
                      const label = labelMap[labelName]
                      if (!label) return ''
                      const styles = getLabelStyles(label.color)
                      return `<span style="background: ${escapeHtml(styles.background)}; color: ${escapeHtml(styles.foreground)}; padding: 1px 6px; border-radius: 10px; font-size: 10px; font-weight: 500;">${escapeHtml(label.displayName || label.name)}</span>`
                    }).join('')}
                  </div>
                ` : ''}
              </div>
            </div>
            <div class="issue-comments">💬 ${issue.comments?.length || 0}</div>
            <div class="issue-status ${escapeHtml(issue.status)}">${escapeHtml(issue.status)}</div>
          </div>
        `).join('')}
      </div>
    `}
    </div>

    <style>
      .issue-filter-btn {
        background: transparent;
        border: none;
        color: #8b949e;
        padding: 6px 12px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 12px;
      }
      .issue-filter-btn:hover { background: #30363d; color: #c9d1d9; }
      .issue-filter-btn.active { background: #238636; color: white; }
      .label-filter-btn.active {
        background: currentColor !important;
        color: white !important;
      }
    </style>

    <script>
      // Issue filtering state
      window.issueFilters = {
        status: 'open',
        labels: [],
        search: ''
      };

      // Store original issues data for client-side filtering
      window.issuesData = ${JSON.stringify(issues)};
      window.labelsData = ${JSON.stringify(labels)};

      function filterByStatus(status) {
        window.issueFilters.status = status;
        document.querySelectorAll('.issue-filter-btn[data-status]').forEach(btn => {
          btn.classList.toggle('active', btn.dataset.status === status);
        });
        applyFilters();
      }

      function toggleLabelFilter(labelName) {
        const idx = window.issueFilters.labels.indexOf(labelName);
        if (idx >= 0) {
          window.issueFilters.labels.splice(idx, 1);
        } else {
          window.issueFilters.labels.push(labelName);
        }
        document.querySelectorAll('.label-filter-btn[data-label]').forEach(btn => {
          btn.classList.toggle('active', window.issueFilters.labels.includes(btn.dataset.label));
        });
        applyFilters();
      }

      function clearLabelFilters() {
        window.issueFilters.labels = [];
        document.querySelectorAll('.label-filter-btn').forEach(btn => btn.classList.remove('active'));
        applyFilters();
      }

      function searchIssues(query) {
        window.issueFilters.search = query.toLowerCase();
        applyFilters();
      }

      function sortIssues(sortBy) {
        applyFilters();
      }

      function applyFilters() {
        const items = document.querySelectorAll('.issue-item');
        const sortBy = document.getElementById('issue-sort')?.value || 'newest';

        items.forEach(item => {
          const status = item.dataset.status;
          const labels = (item.dataset.labels || '').split(',').filter(Boolean);
          const title = item.querySelector('.issue-title')?.textContent?.toLowerCase() || '';

          let visible = true;

          // Status filter
          if (window.issueFilters.status !== 'all' && status !== window.issueFilters.status) {
            visible = false;
          }

          // Label filter (must have ALL selected labels)
          if (visible && window.issueFilters.labels.length > 0) {
            const hasAllLabels = window.issueFilters.labels.every(l => labels.includes(l));
            if (!hasAllLabels) visible = false;
          }

          // Search filter
          if (visible && window.issueFilters.search) {
            if (!title.includes(window.issueFilters.search)) {
              visible = false;
            }
          }

          item.style.display = visible ? 'flex' : 'none';
        });
      }

      // Apply initial filter (show open issues by default)
      document.addEventListener('DOMContentLoaded', function() {
        filterByStatus('open');
      });
    </script>
  `
}

function renderPeersTab(writers, repoName) {
  if (writers.length === 0) {
    return `
      <div class="empty-state">
        <div class="empty-state-icon">🔗</div>
        <div>No peers yet</div>
        <div style="font-size: 12px; margin-top: 10px;">Create an invite to share this linked repo with collaborators</div>
      </div>
      <div class="invite-box">
        <h3>Create Invite</h3>
        <p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;">Generate an invite code to share with collaborators. They can join with: <code style="background: #0d1117; padding: 2px 6px; border-radius: 3px;">pgh repo join &lt;invite&gt;</code></p>
        <button class="primary" onclick="createInvite('${escapeHtml(repoName)}')">+ Create Invite</button>
        <div class="invite-result" id="invite-result"></div>
        <button class="secondary" style="margin-top: 8px; display: none;" id="copy-invite-btn" onclick="copyInvite()">📋 Copy</button>
      </div>
    `
  }

  return `
    <div class="peers-table">
      <div class="peers-header">
        <div>User</div>
        <div>Role</div>
        <div>Server Key</div>
      </div>
      ${writers.map(w => {
        const role = w.role || 'writer'
        const userId = w.userId || 'unknown'
        const serverKey = w.serverKey || ''
        return `
          <div class="peers-row">
            <div class="peer-user">${escapeHtml(userId)}</div>
            <div><span class="peer-role ${escapeHtml(role)}">${escapeHtml(role)}</span></div>
            <div class="peer-key" title="${escapeHtml(serverKey)}">${serverKey ? serverKey.slice(0, 16) + '...' : '-'}</div>
          </div>
        `
      }).join('')}
    </div>

    <div class="invite-box">
      <h3>Create Invite</h3>
      <p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;">Generate an invite code to share with collaborators. They can join with: <code style="background: #0d1117; padding: 2px 6px; border-radius: 3px;">pgh repo join &lt;invite&gt;</code></p>
      <div style="display: flex; gap: 10px; align-items: center;">
        <button class="primary" onclick="createInvite('${escapeHtml(repoName)}')">+ Create Invite</button>
        <button class="secondary" style="display: none;" id="copy-invite-btn" onclick="copyInvite()">📋 Copy</button>
      </div>
      <div class="invite-result" id="invite-result"></div>
    </div>
  `
}

function renderReleasesTab (repo, lastDeploy, pathPrefix) {
  const prefix = pathPrefix || ''
  const owner = repo.username || 'local'
  const tarUrl = `${prefix}/${owner}/${repo.name}/archive/release.tar.gz`
  const zipUrl = `${prefix}/${owner}/${repo.name}/archive/release.zip`

  const deployInfo = lastDeploy
    ? `<p style="color: #8b949e; font-size: 13px;">Last deployed: ${new Date(lastDeploy.timestamp).toLocaleString()} ${lastDeploy.commitHash ? '(' + lastDeploy.commitHash.slice(0, 8) + ')' : ''}</p>`
    : ''

  return `
    <div style="max-width: 700px;">
      <div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 24px; margin-bottom: 20px;">
        <h3 style="margin: 0 0 8px 0; color: #c9d1d9;">Latest Release</h3>
        ${deployInfo}
        <div style="display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap;">
          <a href="${escapeHtml(tarUrl)}" style="display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: #238636; color: #fff; border-radius: 6px; text-decoration: none; font-size: 14px; font-weight: 500;">
            📦 Download .tar.gz
          </a>
          <a href="${escapeHtml(zipUrl)}" style="display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; text-decoration: none; font-size: 14px;">
            📦 Download .zip
          </a>
        </div>
      </div>

      <div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px;">
        <h4 style="margin: 0 0 12px 0; color: #c9d1d9;">Install</h4>
        <pre style="background: #0d1117; border: 1px solid #30363d; border-radius: 4px; padding: 12px; color: #c9d1d9; overflow-x: auto; font-size: 13px;">pgh app install --app ${escapeHtml(repo.name)}</pre>
        <p style="color: #8b949e; font-size: 12px; margin-top: 8px;">Re-running this command auto-updates if already installed.</p>
      </div>
    </div>
  `
}

// Helper function to calculate label text color based on background
function getLabelStyles(color) {
  if (!color) return { background: '#ccc', foreground: '#000' }
  const hex = color.replace('#', '')
  const r = parseInt(hex.substr(0, 2), 16) || 0
  const g = parseInt(hex.substr(2, 2), 16) || 0
  const b = parseInt(hex.substr(4, 2), 16) || 0
  const brightness = (r * 299 + g * 587 + b * 114) / 1000
  return {
    background: color,
    foreground: brightness > 125 ? '#000000' : '#ffffff'
  }
}