feat: issues board - add submitter name, comments, and edit support

This commit is contained in:
Erik 2026-04-10 17:36:08 +02:00
parent 21e72b438f
commit f96171a345
3 changed files with 313 additions and 28 deletions

View file

@ -4135,6 +4135,12 @@ const ISSUE_CATEGORIES = {
other: { label: 'Other', color: '#888888' },
};
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showIssuesWindow() {
const { win, content, isNew } = createWindow(
'issuesWindow', 'Issues Board', 'issues-window'
@ -4145,8 +4151,8 @@ function showIssuesWindow() {
return;
}
win.style.width = '500px';
win.style.height = '450px';
win.style.width = '540px';
win.style.height = '520px';
// Issues list container
const listDiv = document.createElement('div');
@ -4159,6 +4165,7 @@ function showIssuesWindow() {
form.className = 'issues-form';
form.innerHTML = `
<div style="display:flex;gap:4px;margin-bottom:4px;">
<input type="text" id="issueAuthor" placeholder="Your name..." style="width:120px;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<input type="text" id="issueTitle" placeholder="Issue title..." style="flex:1;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<select id="issueCategory" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<option value="plugin">Plugin</option>
@ -4175,17 +4182,24 @@ function showIssuesWindow() {
`;
content.appendChild(form);
// Remember author name in localStorage
const authorInput = form.querySelector('#issueAuthor');
authorInput.value = localStorage.getItem('issueAuthorName') || '';
// Add button handler
form.querySelector('#issueAddBtn').addEventListener('click', async () => {
const author = document.getElementById('issueAuthor').value.trim() || 'Anonymous';
const title = document.getElementById('issueTitle').value.trim();
const desc = document.getElementById('issueDescription').value.trim();
const cat = document.getElementById('issueCategory').value;
if (!title) return;
localStorage.setItem('issueAuthorName', author);
await fetch('/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc, category: cat, author: 'User' })
body: JSON.stringify({ title, description: desc, category: cat, author })
});
document.getElementById('issueTitle').value = '';
document.getElementById('issueDescription').value = '';
@ -4217,24 +4231,38 @@ async function refreshIssuesList(win) {
const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
const isResolved = !!issue.resolved;
const author = issue.author || 'User';
const comments = issue.comments || [];
const row = document.createElement('div');
row.className = 'issue-row' + (isResolved ? ' issue-resolved' : '');
row.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;">
<span class="issue-category" style="background:${cat.color}">${cat.label}</span>
<strong style="font-size:0.8rem;">${issue.title}</strong>
<span style="margin-left:auto;color:#666;font-size:0.65rem;">${date}</span>
</div>
${issue.description ? `<div class="issue-description">${issue.description}</div>` : ''}
`;
const headerDiv = row.querySelector('div');
// Header line
const headerDiv = document.createElement('div');
headerDiv.className = 'issue-header';
headerDiv.innerHTML = `
<span class="issue-category" style="background:${cat.color}">${cat.label}</span>
<strong style="font-size:0.8rem;">${escapeHtml(issue.title)}</strong>
<span class="issue-author">by ${escapeHtml(author)}</span>
<span style="margin-left:auto;color:#666;font-size:0.65rem;">${date}</span>
`;
row.appendChild(headerDiv);
// Description
if (issue.description) {
const descDiv = document.createElement('div');
descDiv.className = 'issue-description';
descDiv.textContent = issue.description;
row.appendChild(descDiv);
}
// Action buttons
const actionsDiv = document.createElement('div');
actionsDiv.className = 'issue-actions';
if (isResolved) {
// Reopen button
const reopenBtn = document.createElement('button');
reopenBtn.textContent = '↺ Reopen';
reopenBtn.textContent = '\u21BA Reopen';
reopenBtn.className = 'issue-reopen-btn';
reopenBtn.addEventListener('click', async () => {
await fetch(`/issues/${issue.id}`, {
@ -4244,22 +4272,20 @@ async function refreshIssuesList(win) {
});
refreshIssuesList(win);
});
headerDiv.appendChild(reopenBtn);
actionsDiv.appendChild(reopenBtn);
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '🗑 Delete';
deleteBtn.textContent = '\uD83D\uDDD1 Delete';
deleteBtn.className = 'issue-delete-btn';
deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete issue "${issue.title}"?`)) return;
await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
refreshIssuesList(win);
});
headerDiv.appendChild(deleteBtn);
actionsDiv.appendChild(deleteBtn);
} else {
// Resolve button (marks as resolved, doesn't delete)
const resolveBtn = document.createElement('button');
resolveBtn.textContent = ' Resolve';
resolveBtn.textContent = '\u2713 Resolve';
resolveBtn.className = 'issue-resolve-btn';
resolveBtn.addEventListener('click', async () => {
await fetch(`/issues/${issue.id}`, {
@ -4269,12 +4295,136 @@ async function refreshIssuesList(win) {
});
refreshIssuesList(win);
});
headerDiv.appendChild(resolveBtn);
actionsDiv.appendChild(resolveBtn);
}
// Edit button
const editBtn = document.createElement('button');
editBtn.textContent = '\u270E Edit';
editBtn.className = 'issue-edit-btn';
editBtn.addEventListener('click', () => {
showEditIssueForm(row, issue, win);
});
actionsDiv.appendChild(editBtn);
// Comment toggle button
const commentBtn = document.createElement('button');
commentBtn.textContent = `\uD83D\uDCAC ${comments.length}`;
commentBtn.className = 'issue-comment-btn';
commentBtn.addEventListener('click', () => {
const existing = row.querySelector('.issue-comments-section');
if (existing) {
existing.remove();
} else {
showCommentsSection(row, issue, win);
}
});
actionsDiv.appendChild(commentBtn);
row.appendChild(actionsDiv);
listDiv.appendChild(row);
});
} catch (err) {
listDiv.innerHTML = '<div style="padding:10px;color:#c44;font-size:0.8rem;">Failed to load issues</div>';
}
}
function showEditIssueForm(row, issue, win) {
// Remove any existing edit form
const existing = row.querySelector('.issue-edit-form');
if (existing) { existing.remove(); return; }
const cat = issue.category || 'other';
const form = document.createElement('div');
form.className = 'issue-edit-form';
form.innerHTML = `
<div style="display:flex;gap:4px;margin-bottom:4px;">
<input type="text" class="edit-title" value="${escapeHtml(issue.title)}" style="flex:1;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<select class="edit-category" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<option value="plugin"${cat === 'plugin' ? ' selected' : ''}>Plugin</option>
<option value="overlord"${cat === 'overlord' ? ' selected' : ''}>Overlord</option>
<option value="nav"${cat === 'nav' ? ' selected' : ''}>Nav</option>
<option value="macro"${cat === 'macro' ? ' selected' : ''}>Macro</option>
<option value="other"${cat === 'other' ? ' selected' : ''}>Other</option>
</select>
</div>
<div style="display:flex;gap:4px;">
<textarea class="edit-description" rows="2" style="flex:1;padding:3px 6px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;resize:vertical;">${escapeHtml(issue.description || '')}</textarea>
<div style="display:flex;flex-direction:column;gap:2px;">
<button class="edit-save-btn" style="padding:3px 8px;background:#4a80c0;color:#fff;border:1px solid #336699;cursor:pointer;font-size:0.7rem;">Save</button>
<button class="edit-cancel-btn" style="padding:3px 8px;background:#444;color:#ccc;border:1px solid #555;cursor:pointer;font-size:0.7rem;">Cancel</button>
</div>
</div>
`;
form.querySelector('.edit-save-btn').addEventListener('click', async () => {
const title = form.querySelector('.edit-title').value.trim();
const desc = form.querySelector('.edit-description').value.trim();
const category = form.querySelector('.edit-category').value;
if (!title) return;
await fetch(`/issues/${issue.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc, category })
});
refreshIssuesList(win);
});
form.querySelector('.edit-cancel-btn').addEventListener('click', () => {
form.remove();
});
row.appendChild(form);
}
function showCommentsSection(row, issue, win) {
const section = document.createElement('div');
section.className = 'issue-comments-section';
const comments = issue.comments || [];
// Render existing comments
const commentsListDiv = document.createElement('div');
commentsListDiv.className = 'issue-comments-list';
if (comments.length === 0) {
commentsListDiv.innerHTML = '<div style="color:#666;font-size:0.7rem;padding:2px 0;">No comments yet</div>';
} else {
comments.forEach(c => {
const cDiv = document.createElement('div');
cDiv.className = 'issue-comment';
const cDate = c.created ? new Date(c.created).toLocaleDateString('sv-SE') : '';
cDiv.innerHTML = `<span class="comment-author">${escapeHtml(c.author || 'Anonymous')}</span> <span class="comment-date">${cDate}</span>`;
const textDiv = document.createElement('div');
textDiv.className = 'comment-text';
textDiv.textContent = c.text;
cDiv.appendChild(textDiv);
commentsListDiv.appendChild(cDiv);
});
}
section.appendChild(commentsListDiv);
// Add comment form
const addDiv = document.createElement('div');
addDiv.className = 'issue-comment-form';
addDiv.innerHTML = `
<div style="display:flex;gap:4px;">
<input type="text" class="comment-text-input" placeholder="Add a comment..." style="flex:1;padding:3px 6px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<button class="comment-add-btn" style="padding:3px 8px;background:#4a80c0;color:#fff;border:1px solid #336699;cursor:pointer;font-size:0.7rem;">Post</button>
</div>
`;
addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => {
const text = addDiv.querySelector('.comment-text-input').value.trim();
if (!text) return;
const author = localStorage.getItem('issueAuthorName') || 'Anonymous';
await fetch(`/issues/${issue.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, author })
});
refreshIssuesList(win);
});
section.appendChild(addDiv);
row.appendChild(section);
}

View file

@ -2644,8 +2644,8 @@ table.ts-allegiance td:first-child {
/* ─── Issues Board ─── */
.issues-window {
width: 500px;
height: 450px;
width: 540px;
height: 520px;
}
.issues-window .window-content {
@ -2672,6 +2672,18 @@ table.ts-allegiance td:first-child {
background: #1a1a2a;
}
.issue-header {
display: flex;
align-items: center;
gap: 6px;
}
.issue-author {
color: #8a8a6a;
font-size: 0.65rem;
font-style: italic;
}
.issue-row .issue-description {
color: #aaa;
font-size: 0.75rem;
@ -2703,15 +2715,22 @@ table.ts-allegiance td:first-child {
text-transform: uppercase;
}
.issue-actions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.issue-resolve-btn,
.issue-reopen-btn,
.issue-delete-btn {
.issue-delete-btn,
.issue-edit-btn,
.issue-comment-btn {
padding: 1px 6px;
font-size: 0.65rem;
background: transparent;
cursor: pointer;
border-radius: 3px;
margin-left: 4px;
}
.issue-resolve-btn {
@ -2744,8 +2763,83 @@ table.ts-allegiance td:first-child {
color: #fff;
}
.issue-edit-btn {
color: #ca4;
border: 1px solid #ca4;
}
.issue-edit-btn:hover {
background: #ca4;
color: #fff;
}
.issue-comment-btn {
color: #6af;
border: 1px solid #6af;
}
.issue-comment-btn:hover {
background: #6af;
color: #fff;
}
.issues-form {
padding: 6px 8px;
border-top: 1px solid #444;
background: #1a1a1a;
}
/* Edit form */
.issue-edit-form {
margin-top: 6px;
padding: 6px;
background: #1a1a2a;
border: 1px solid #444;
border-radius: 3px;
}
/* Comments section */
.issue-comments-section {
margin-top: 6px;
padding: 6px;
background: #151520;
border: 1px solid #333;
border-radius: 3px;
}
.issue-comments-list {
max-height: 120px;
overflow-y: auto;
margin-bottom: 4px;
}
.issue-comment {
padding: 3px 0;
border-bottom: 1px solid #2a2a2a;
}
.issue-comment:last-child {
border-bottom: none;
}
.comment-author {
color: #8a8a6a;
font-size: 0.7rem;
font-weight: bold;
}
.comment-date {
color: #555;
font-size: 0.6rem;
}
.comment-text {
color: #bbb;
font-size: 0.75rem;
padding-left: 4px;
margin-top: 1px;
}
.issue-comment-form {
margin-top: 4px;
}