From f96171a345605959b5cc233a2132cc4f37c9f346 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 17:36:08 +0200 Subject: [PATCH] feat: issues board - add submitter name, comments, and edit support --- main.py | 47 +++++++++++- static/script.js | 192 +++++++++++++++++++++++++++++++++++++++++------ static/style.css | 102 ++++++++++++++++++++++++- 3 files changed, 313 insertions(+), 28 deletions(-) diff --git a/main.py b/main.py index 3d10310a..17494121 100644 --- a/main.py +++ b/main.py @@ -1369,6 +1369,7 @@ async def add_issue(issue: dict): "author": issue.get("author", "Anonymous").strip(), "created": datetime.utcnow().isoformat(), "resolved": False, + "comments": [], } if not new_issue["title"]: raise HTTPException(status_code=400, detail="Title is required") @@ -1379,13 +1380,22 @@ async def add_issue(issue: dict): @app.patch("/issues/{issue_id}") 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() found = None for i in issues: if i.get("id") == issue_id: if "resolved" in update: 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 break if not found: @@ -1394,6 +1404,33 @@ async def update_issue(issue_id: str, update: dict): 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}") async def delete_issue(issue_id: str): """Permanently delete an issue.""" @@ -2768,7 +2805,9 @@ async def ws_receive_snapshots( landblock = data.get("landblock") if landblock: 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) continue # Unknown message types are ignored @@ -3328,7 +3367,9 @@ class NoCacheStaticFiles(StaticFiles): # Check content-type header since root path "" resolves to index.html # via html=True and we need to catch it too. 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" return response diff --git a/static/script.js b/static/script.js index 4b6f8882..81641cd4 100644 --- a/static/script.js +++ b/static/script.js @@ -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 = `
+ + +
+
+ +
+ + +
+
+ `; + + 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 = '
No comments yet
'; + } 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 = `${escapeHtml(c.author || 'Anonymous')} ${cDate}`; + 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 = ` +
+ + +
+ `; + + 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); +} diff --git a/static/style.css b/static/style.css index 61254ed8..fc897014 100644 --- a/static/style.css +++ b/static/style.css @@ -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; +}