/**
* 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">×</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 }