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

47
main.py
View file

@ -1369,6 +1369,7 @@ async def add_issue(issue: dict):
"author": issue.get("author", "Anonymous").strip(), "author": issue.get("author", "Anonymous").strip(),
"created": datetime.utcnow().isoformat(), "created": datetime.utcnow().isoformat(),
"resolved": False, "resolved": False,
"comments": [],
} }
if not new_issue["title"]: if not new_issue["title"]:
raise HTTPException(status_code=400, detail="Title is required") raise HTTPException(status_code=400, detail="Title is required")
@ -1379,13 +1380,22 @@ async def add_issue(issue: dict):
@app.patch("/issues/{issue_id}") @app.patch("/issues/{issue_id}")
async def update_issue(issue_id: str, update: dict): async def update_issue(issue_id: str, update: dict):
"""Update an issue (e.g. toggle resolved state).""" """Update an issue (toggle resolved, edit title/description/category)."""
issues = _load_issues() issues = _load_issues()
found = None found = None
for i in issues: for i in issues:
if i.get("id") == issue_id: if i.get("id") == issue_id:
if "resolved" in update: if "resolved" in update:
i["resolved"] = bool(update["resolved"]) i["resolved"] = bool(update["resolved"])
if "title" in update:
title = update["title"].strip()
if not title:
raise HTTPException(status_code=400, detail="Title cannot be empty")
i["title"] = title
if "description" in update:
i["description"] = update["description"].strip()
if "category" in update:
i["category"] = update["category"]
found = i found = i
break break
if not found: if not found:
@ -1394,6 +1404,33 @@ async def update_issue(issue_id: str, update: dict):
return found return found
@app.post("/issues/{issue_id}/comments")
async def add_comment(issue_id: str, comment: dict):
"""Add a comment to an issue."""
issues = _load_issues()
found = None
for i in issues:
if i.get("id") == issue_id:
found = i
break
if not found:
raise HTTPException(status_code=404, detail="Issue not found")
text = comment.get("text", "").strip()
if not text:
raise HTTPException(status_code=400, detail="Comment text is required")
new_comment = {
"id": uuid.uuid4().hex[:8],
"author": comment.get("author", "Anonymous").strip(),
"text": text,
"created": datetime.utcnow().isoformat(),
}
if "comments" not in found:
found["comments"] = []
found["comments"].append(new_comment)
_save_issues(issues)
return new_comment
@app.delete("/issues/{issue_id}") @app.delete("/issues/{issue_id}")
async def delete_issue(issue_id: str): async def delete_issue(issue_id: str):
"""Permanently delete an issue.""" """Permanently delete an issue."""
@ -2768,7 +2805,9 @@ async def ws_receive_snapshots(
landblock = data.get("landblock") landblock = data.get("landblock")
if landblock: if landblock:
dungeon_map_cache[landblock] = data dungeon_map_cache[landblock] = data
logger.info(f"Cached dungeon map for {landblock} ({len(data.get('z_levels', []))} z-levels)") logger.info(
f"Cached dungeon map for {landblock} ({len(data.get('z_levels', []))} z-levels)"
)
await _broadcast_to_browser_clients(data) await _broadcast_to_browser_clients(data)
continue continue
# Unknown message types are ignored # Unknown message types are ignored
@ -3328,7 +3367,9 @@ class NoCacheStaticFiles(StaticFiles):
# Check content-type header since root path "" resolves to index.html # Check content-type header since root path "" resolves to index.html
# via html=True and we need to catch it too. # via html=True and we need to catch it too.
ct = response.headers.get("content-type", "").lower() ct = response.headers.get("content-type", "").lower()
if any(t in ct for t in ("text/html", "javascript", "text/css", "application/json")): if any(
t in ct for t in ("text/html", "javascript", "text/css", "application/json")
):
response.headers["Cache-Control"] = "no-cache, must-revalidate" response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response return response

View file

@ -4135,6 +4135,12 @@ const ISSUE_CATEGORIES = {
other: { label: 'Other', color: '#888888' }, other: { label: 'Other', color: '#888888' },
}; };
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showIssuesWindow() { function showIssuesWindow() {
const { win, content, isNew } = createWindow( const { win, content, isNew } = createWindow(
'issuesWindow', 'Issues Board', 'issues-window' 'issuesWindow', 'Issues Board', 'issues-window'
@ -4145,8 +4151,8 @@ function showIssuesWindow() {
return; return;
} }
win.style.width = '500px'; win.style.width = '540px';
win.style.height = '450px'; win.style.height = '520px';
// Issues list container // Issues list container
const listDiv = document.createElement('div'); const listDiv = document.createElement('div');
@ -4159,6 +4165,7 @@ function showIssuesWindow() {
form.className = 'issues-form'; form.className = 'issues-form';
form.innerHTML = ` form.innerHTML = `
<div style="display:flex;gap:4px;margin-bottom:4px;"> <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;"> <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;"> <select id="issueCategory" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<option value="plugin">Plugin</option> <option value="plugin">Plugin</option>
@ -4175,17 +4182,24 @@ function showIssuesWindow() {
`; `;
content.appendChild(form); content.appendChild(form);
// Remember author name in localStorage
const authorInput = form.querySelector('#issueAuthor');
authorInput.value = localStorage.getItem('issueAuthorName') || '';
// Add button handler // Add button handler
form.querySelector('#issueAddBtn').addEventListener('click', async () => { form.querySelector('#issueAddBtn').addEventListener('click', async () => {
const author = document.getElementById('issueAuthor').value.trim() || 'Anonymous';
const title = document.getElementById('issueTitle').value.trim(); const title = document.getElementById('issueTitle').value.trim();
const desc = document.getElementById('issueDescription').value.trim(); const desc = document.getElementById('issueDescription').value.trim();
const cat = document.getElementById('issueCategory').value; const cat = document.getElementById('issueCategory').value;
if (!title) return; if (!title) return;
localStorage.setItem('issueAuthorName', author);
await fetch('/issues', { await fetch('/issues', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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('issueTitle').value = '';
document.getElementById('issueDescription').value = ''; document.getElementById('issueDescription').value = '';
@ -4217,24 +4231,38 @@ async function refreshIssuesList(win) {
const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : ''; const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
const isResolved = !!issue.resolved; const isResolved = !!issue.resolved;
const author = issue.author || 'User';
const comments = issue.comments || [];
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'issue-row' + (isResolved ? ' issue-resolved' : ''); 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) { if (isResolved) {
// Reopen button
const reopenBtn = document.createElement('button'); const reopenBtn = document.createElement('button');
reopenBtn.textContent = '↺ Reopen'; reopenBtn.textContent = '\u21BA Reopen';
reopenBtn.className = 'issue-reopen-btn'; reopenBtn.className = 'issue-reopen-btn';
reopenBtn.addEventListener('click', async () => { reopenBtn.addEventListener('click', async () => {
await fetch(`/issues/${issue.id}`, { await fetch(`/issues/${issue.id}`, {
@ -4244,22 +4272,20 @@ async function refreshIssuesList(win) {
}); });
refreshIssuesList(win); refreshIssuesList(win);
}); });
headerDiv.appendChild(reopenBtn); actionsDiv.appendChild(reopenBtn);
// Delete button
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.textContent = '🗑 Delete'; deleteBtn.textContent = '\uD83D\uDDD1 Delete';
deleteBtn.className = 'issue-delete-btn'; deleteBtn.className = 'issue-delete-btn';
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete issue "${issue.title}"?`)) return; if (!confirm(`Delete issue "${issue.title}"?`)) return;
await fetch(`/issues/${issue.id}`, { method: 'DELETE' }); await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
refreshIssuesList(win); refreshIssuesList(win);
}); });
headerDiv.appendChild(deleteBtn); actionsDiv.appendChild(deleteBtn);
} else { } else {
// Resolve button (marks as resolved, doesn't delete)
const resolveBtn = document.createElement('button'); const resolveBtn = document.createElement('button');
resolveBtn.textContent = ' Resolve'; resolveBtn.textContent = '\u2713 Resolve';
resolveBtn.className = 'issue-resolve-btn'; resolveBtn.className = 'issue-resolve-btn';
resolveBtn.addEventListener('click', async () => { resolveBtn.addEventListener('click', async () => {
await fetch(`/issues/${issue.id}`, { await fetch(`/issues/${issue.id}`, {
@ -4269,12 +4295,136 @@ async function refreshIssuesList(win) {
}); });
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); listDiv.appendChild(row);
}); });
} catch (err) { } catch (err) {
listDiv.innerHTML = '<div style="padding:10px;color:#c44;font-size:0.8rem;">Failed to load issues</div>'; 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 Board ─── */
.issues-window { .issues-window {
width: 500px; width: 540px;
height: 450px; height: 520px;
} }
.issues-window .window-content { .issues-window .window-content {
@ -2672,6 +2672,18 @@ table.ts-allegiance td:first-child {
background: #1a1a2a; 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 { .issue-row .issue-description {
color: #aaa; color: #aaa;
font-size: 0.75rem; font-size: 0.75rem;
@ -2703,15 +2715,22 @@ table.ts-allegiance td:first-child {
text-transform: uppercase; text-transform: uppercase;
} }
.issue-actions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.issue-resolve-btn, .issue-resolve-btn,
.issue-reopen-btn, .issue-reopen-btn,
.issue-delete-btn { .issue-delete-btn,
.issue-edit-btn,
.issue-comment-btn {
padding: 1px 6px; padding: 1px 6px;
font-size: 0.65rem; font-size: 0.65rem;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 3px;
margin-left: 4px;
} }
.issue-resolve-btn { .issue-resolve-btn {
@ -2744,8 +2763,83 @@ table.ts-allegiance td:first-child {
color: #fff; 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 { .issues-form {
padding: 6px 8px; padding: 6px 8px;
border-top: 1px solid #444; border-top: 1px solid #444;
background: #1a1a1a; 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;
}