OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / issue-page-ui.js

373 lines · javascript ref:
/**
 * Issue Page UI — server-rendered HTML for viewing a single issue with comments.
 *
 * Extracted from routes/issues.js to separate rendering from routing.
 */

import { escapeHtml } from './response.js'
import { renderIdentityScript } from './identity-ui.js'
import { renderNav } from './nav-ui.js'
import { parseMarkdown, getLabelStyles } from './markdown.js'

function renderIssuePage(repoName, issue, descriptionHtml, pathPrefix, labels = [], username = 'local') {
  // Create label map for quick lookup
  const labelMap = {}
  labels.forEach(l => { labelMap[l.name] = l })

  // Render current issue labels
  const issueLabels = issue.labels || []
  const labelsHtml = issueLabels.map(labelName => {
    const label = labelMap[labelName]
    if (!label) return ''
    const styles = getLabelStyles(label.color)
    return `<span class="issue-label" data-label="${escapeHtml(label.name)}" style="background: ${styles.background}; color: ${styles.foreground}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; display: inline-flex; align-items: center; gap: 4px;">
      ${escapeHtml(label.displayName || label.name)}
      <button data-remove-label="${escapeHtml(label.name)}" onclick="removeLabel(this.dataset.removeLabel)" style="background: none; border: none; color: inherit; cursor: pointer; font-size: 14px; line-height: 1; padding: 0; margin-left: 2px;" title="Remove label">&times;</button>
    </span>`
  }).join('')

  return `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Issue #${issue.number}: ${escapeHtml(issue.title)} - ${escapeHtml(repoName)}</title>
  <style>
    body { font-family: system-ui; background: #0d1117; color: #c9d1d9; padding: 20px; }
    .container { max-width: 900px; margin: 0 auto; }
    .back-link { color: #58a6ff; text-decoration: none; margin-bottom: 20px; display: inline-block; }
    h1 { font-size: 28px; margin: 20px 0; }
    .issue-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; }
    .issue-status.open { background: #238636; color: white; }
    .issue-status.closed { background: #8250df; color: white; }
    .issue-body { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin: 20px 0; line-height: 1.6; }
    .issue-image { max-width: 100%; border-radius: 6px; margin: 10px 0; }
    code { background: #0d1117; padding: 2px 6px; border-radius: 3px; font-family: monospace; font-size: 12px; }
    pre { background: #0d1117; padding: 16px; border-radius: 6px; overflow-x: auto; }
    pre code { background: none; padding: 0; }
    .comment { transition: background 0.3s ease, border-color 0.3s ease; }
    .comment:target, .comment.highlighted { background: #1c2d3f !important; border-color: #58a6ff !important; }
    .comment-link-btn { opacity: 0; background: none; border: none; color: #8b949e; cursor: pointer; font-size: 12px; padding: 2px 6px; border-radius: 3px; transition: opacity 0.15s; }
    .comment:hover .comment-link-btn { opacity: 1; }
    .comment-link-btn:hover { color: #58a6ff; background: #0d1117; }
    .button { padding: 8px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; font-size: 14px; margin-top: 20px; }
    .button:hover { background: #30363d; }
    .labels-section { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 15px; margin: 15px 0; }
    .labels-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
    .labels-list { display: flex; flex-wrap: wrap; gap: 6px; }
    .add-label-btn { background: transparent; border: 1px dashed #30363d; color: #8b949e; padding: 2px 8px; border-radius: 12px; font-size: 11px; cursor: pointer; }
    .add-label-btn:hover { border-color: #58a6ff; color: #58a6ff; }
    .label-dropdown { position: relative; display: inline-block; }
    .label-dropdown-content { display: none; position: absolute; background: #161b22; border: 1px solid #30363d; border-radius: 6px; min-width: 200px; z-index: 100; top: 100%; left: 0; margin-top: 4px; max-height: 300px; overflow-y: auto; }
    .label-dropdown-content.show { display: block; }
    .label-option { padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
    .label-option:hover { background: #0d1117; }
    .label-color-dot { width: 12px; height: 12px; border-radius: 50%; }
  </style>
</head>
<body>
  ${renderNav(pathPrefix, 'repo')}
  <div class="container">
    <a href="${pathPrefix}/${escapeHtml(username)}/${escapeHtml(repoName)}" class="back-link">← Back to ${escapeHtml(repoName)}</a>
    <h1>
      #${issue.number}: ${escapeHtml(issue.title)}
      <span class="issue-status ${escapeHtml(issue.status)}">${escapeHtml(issue.status)}</span>
    </h1>

    <!-- Labels Section -->
    <div class="labels-section" id="labels-section">
      <div class="labels-header">
        <span style="font-weight: 500; font-size: 14px;">Labels</span>
        <div class="label-dropdown" id="label-dropdown" style="display: none;">
          <button class="add-label-btn" onclick="toggleLabelDropdown()">+ Add label</button>
          <div class="label-dropdown-content" id="label-dropdown-content">
            ${labels.map(label => {
              const isApplied = issueLabels.includes(label.name)
              return `
                <div class="label-option" data-label="${escapeHtml(label.name)}" onclick="toggleLabel(this.dataset.label)" style="${isApplied ? 'background: #0d1117;' : ''}">
                  <span class="label-color-dot" style="background: ${escapeHtml(label.color)};"></span>
                  <span style="flex: 1;">${escapeHtml(label.displayName || label.name)}</span>
                  ${isApplied ? '<span style="color: #238636;">✓</span>' : ''}
                </div>
              `
            }).join('')}
            ${labels.length === 0 ? '<div style="padding: 12px; color: #8b949e; text-align: center;">No labels available</div>' : ''}
          </div>
        </div>
      </div>
      <div class="labels-list" id="labels-list">
        ${labelsHtml || '<span style="color: #8b949e; font-size: 12px;">No labels</span>'}
      </div>
    </div>

    <div class="issue-body">
      ${descriptionHtml || '<em>No description provided</em>'}
    </div>
    <div style="color: #8b949e; font-size: 12px;">
      Opened ${new Date(issue.createdAt).toLocaleString()}
    </div>
    <button class="button" id="toggle-status" onclick="toggleIssueStatus()" style="display: none;">
      ${issue.status === 'open' ? 'Close Issue' : 'Reopen Issue'}
    </button>
    <div id="unauthorized-message" style="display: none; color: #8b949e; font-size: 14px; margin-top: 20px;">
      Only the issue creator, repo owner, or users with manage-issues permission can close/reopen this issue.
    </div>

    <!-- Comments Section -->
    <div style="margin-top: 40px;">
      <h3 style="margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
        Comments <span style="color: #8b949e; font-size: 14px; font-weight: normal;">(${issue.comments?.length || 0})</span>
      </h3>

      ${issue.comments && issue.comments.length > 0 ? issue.comments.map(comment => `
        <div class="comment" id="comment-${comment.id}" style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 15px; margin: 15px 0;">
          <div style="display: flex; justify-content: space-between; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #30363d;">
            <div style="display: flex; align-items: center; gap: 8px;">
              <span style="font-weight: 600; color: #58a6ff;">${escapeHtml(comment.author || 'Anonymous')}</span>
              <button class="comment-link-btn" onclick="copyCommentLink(${comment.id})" title="Copy link to comment">#${comment.id}</button>
            </div>
            <div style="color: #8b949e; font-size: 12px;">${new Date(comment.createdAt).toLocaleString()}</div>
          </div>
          <div style="line-height: 1.6;">
            ${parseMarkdown(comment.text, comment.imageData || {})}
          </div>
        </div>
      `).join('') : `
        <div style="color: #8b949e; text-align: center; padding: 40px 20px; background: #161b22; border: 1px solid #30363d; border-radius: 6px;">
          No comments yet. Be the first to comment!
        </div>
      `}

      <!-- Add Comment Form -->
      <div id="comment-form" style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-top: 20px;">
        <h4 style="margin-bottom: 15px;">Add a comment</h4>
        <textarea id="comment-text" placeholder="Write your comment... (Markdown supported)" style="width: 100%; min-height: 120px; background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: inherit; resize: vertical; line-height: 1.5;"></textarea>
        <div style="margin-top: 12px; display: flex; gap: 10px; align-items: center;">
          <button onclick="submitComment()" style="background: #238636; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 500;">
            Post Comment
          </button>
          <span id="comment-status" style="color: #8b949e; font-size: 12px;"></span>
        </div>
        <div style="margin-top: 10px; color: #8b949e; font-size: 11px;">
          Supports: **bold**, *italic*, \`code\`, \`\`\`code blocks\`\`\`, [links](url), # headers
        </div>
      </div>
    </div>
  </div>

  ${renderIdentityScript(pathPrefix)}

  <script>
    const issueCreatedBy = ${issue.createdBy ? JSON.stringify(issue.createdBy) : 'null'};

    async function checkModifyPermission() {
      try {
        const response = await window.fetch(getBasePath() + '/api/user/identities');
        if (!response.ok) return false;

        const data = await response.json();
        const hasAdmin = data.permissions.global.includes('admin') ||
                         data.permissions.repos.some(r => r.permissions.includes('admin'));
        const hasWrite = data.permissions.global.includes('write') ||
                         data.permissions.repos.some(r => r.permissions.includes('write'));

        if (hasAdmin || hasWrite) return true;
        if (issueCreatedBy && data.userId === issueCreatedBy) return true;

        return false;
      } catch (err) {
        console.error('Permission check error:', err);
        return false;
      }
    }

    (async function() {
      const canModify = await checkModifyPermission();
      const button = document.getElementById('toggle-status');
      const message = document.getElementById('unauthorized-message');
      const labelDropdown = document.getElementById('label-dropdown');

      if (canModify) {
        button.style.display = 'inline-block';
        if (labelDropdown) labelDropdown.style.display = 'inline-block';
      } else {
        message.style.display = 'block';
      }
    })();

    // Label management functions
    function toggleLabelDropdown() {
      const dropdown = document.getElementById('label-dropdown-content');
      dropdown.classList.toggle('show');
    }

    // Close dropdown when clicking outside
    document.addEventListener('click', function(e) {
      if (!e.target.closest('.label-dropdown')) {
        const dropdown = document.getElementById('label-dropdown-content');
        if (dropdown) dropdown.classList.remove('show');
      }
    });

    async function toggleLabel(labelName) {
      const currentLabels = ${JSON.stringify(issue.labels || [])};
      const hasLabel = currentLabels.includes(labelName);

      try {
        if (hasLabel) {
          await removeLabel(labelName);
        } else {
          await addLabel(labelName);
        }
      } catch (err) {
        alert('Error: ' + err.message);
      }
    }

    async function addLabel(labelName) {
      try {
        const response = await window.fetch(window.location.pathname + '/labels', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ labels: [labelName] })
        });

        if (response.ok) {
          window.location.reload();
        } else {
          const error = await response.json();
          alert('Failed to add label: ' + (error.error || 'Unknown error'));
        }
      } catch (err) {
        alert('Error adding label: ' + err.message);
      }
    }

    async function removeLabel(labelName) {
      try {
        const response = await window.fetch(window.location.pathname + '/labels/' + encodeURIComponent(labelName), {
          method: 'DELETE'
        });

        if (response.ok) {
          window.location.reload();
        } else {
          const error = await response.json();
          alert('Failed to remove label: ' + (error.error || 'Unknown error'));
        }
      } catch (err) {
        alert('Error removing label: ' + err.message);
      }
    }

    async function toggleIssueStatus() {
      const btn = document.getElementById('toggle-status');
      const currentStatus = ${JSON.stringify(issue.status)};
      const newStatus = currentStatus === 'open' ? 'closed' : 'open';

      btn.disabled = true;
      btn.textContent = 'Updating...';

      try {
        const response = await window.fetch(window.location.pathname, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ status: newStatus })
        });

        if (response.ok) {
          window.location.reload();
        } else {
          const error = await response.json();
          alert('Failed to update issue: ' + (error.error || 'Unknown error'));
          btn.disabled = false;
          btn.textContent = currentStatus === 'open' ? 'Close Issue' : 'Reopen Issue';
        }
      } catch (err) {
        alert('Error: ' + err.message);
        btn.disabled = false;
        btn.textContent = currentStatus === 'open' ? 'Close Issue' : 'Reopen Issue';
      }
    }

    async function submitComment() {
      const textarea = document.getElementById('comment-text');
      const status = document.getElementById('comment-status');
      const text = textarea.value.trim();

      if (!text) {
        status.textContent = 'Comment cannot be empty';
        status.style.color = '#f85149';
        return;
      }

      status.textContent = 'Posting...';
      status.style.color = '#8b949e';

      try {
        const response = await window.fetch(window.location.pathname + '/comments', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text })
        });

        if (response.ok) {
          const data = await response.json();
          const commentId = data.comment && data.comment.id;
          if (commentId) {
            window.location.hash = '#comment-' + commentId;
            window.location.reload();
          } else {
            window.location.reload();
          }
        } else {
          const error = await response.json();
          status.textContent = 'Failed: ' + (error.error || 'Unknown error');
          status.style.color = '#f85149';
        }
      } catch (err) {
        status.textContent = 'Error: ' + err.message;
        status.style.color = '#f85149';
      }
    }

    document.getElementById('comment-text').addEventListener('keydown', function(e) {
      if (e.ctrlKey && e.key === 'Enter') {
        submitComment();
      }
    });

    function copyCommentLink(commentId) {
      const url = window.location.origin + window.location.pathname + '#comment-' + commentId;
      navigator.clipboard.writeText(url).then(() => {
        const btn = document.querySelector('#comment-' + commentId + ' .comment-link-btn');
        if (btn) {
          const orig = btn.textContent;
          btn.textContent = 'Copied!';
          btn.style.opacity = '1';
          setTimeout(() => { btn.textContent = orig; btn.style.opacity = ''; }, 1500);
        }
      });
    }

    // Scroll to and highlight comment if URL has hash
    (function() {
      if (window.location.hash) {
        const el = document.querySelector(window.location.hash);
        if (el && el.classList.contains('comment')) {
          setTimeout(() => {
            el.scrollIntoView({ behavior: 'smooth', block: 'center' });
            el.classList.add('highlighted');
            setTimeout(() => el.classList.remove('highlighted'), 3000);
          }, 100);
        }
      }
    })();
  </script>
</body>
</html>
  `
}

export { renderIssuePage }