fix(v2): issues board — full v1 feature parity

Now matches v1's Issues Board exactly:

- Category badges with v1's exact colors (Plugin=#8844cc, Overlord=#4488cc,
  Nav=#44aa44, Macro=#cc8844, Other=#888888)
- Author name + date per issue
- Action buttons:
  - Unresolved: ✓ Resolve + ✎ Edit
  - Resolved: ↻ Reopen + 🗑 Delete (with confirm dialog) + ✎ Edit
- Inline edit form: editable title + category dropdown + description
  textarea + Save/Cancel buttons (toggle with ✎ Edit click)
- Comments section per issue: always visible inline
  - Comment list with author (blue), date, text
  - Add comment input with Post button (Enter key supported)
  - "No comments yet" placeholder
- Add issue form at bottom: title input + category select + description
  textarea + Add button
- Resolved issues dimmed to 55% opacity, sorted below unresolved
- All API calls use /api prefix with credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 22:41:13 +02:00
parent b00c386d77
commit 851fc5f7cd
4 changed files with 264 additions and 167 deletions

View file

@ -2,23 +2,37 @@ import React, { useEffect, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Comment { id: number; text: string; author: string; created: string; }
interface Issue {
id: number; title: string; description: string; category: string;
created: string; resolved: boolean; author: string;
comments?: Array<{ id: number; text: string; author: string; created: string }>;
created: string; resolved: boolean; author: string; comments?: Comment[];
}
interface Props { id: string; zIndex: number; }
const CAT_COLORS: Record<string, string> = {
plugin: '#4488ff', overlord: '#44cc44', nav: '#ffaa00', macro: '#cc44cc', other: '#888',
const CATS: Record<string, { label: string; color: string }> = {
plugin: { label: 'Plugin', color: '#8844cc' },
overlord: { label: 'Overlord', color: '#4488cc' },
nav: { label: 'Nav', color: '#44aa44' },
macro: { label: 'Macro', color: '#cc8844' },
other: { label: 'Other', color: '#888888' },
};
const inputStyle: React.CSSProperties = { padding: '3px 6px', fontSize: '0.8rem', border: '1px solid #555', background: '#2a2a2a', color: '#ddd', borderRadius: 0 };
const selectStyle: React.CSSProperties = { ...inputStyle, fontSize: '0.75rem' };
const btnBlue: React.CSSProperties = { padding: '4px 12px', background: '#4a80c0', color: '#fff', border: '1px solid #336699', cursor: 'pointer', fontSize: '0.75rem' };
const btnGray: React.CSSProperties = { padding: '3px 8px', background: '#444', color: '#ccc', border: '1px solid #555', cursor: 'pointer', fontSize: '0.7rem' };
export const IssuesWindow: React.FC<Props> = ({ id, zIndex }) => {
const [issues, setIssues] = useState<Issue[]>([]);
const [title, setTitle] = useState('');
const [desc, setDesc] = useState('');
const [category, setCategory] = useState('plugin');
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editCat, setEditCat] = useState('');
const [commentText, setCommentText] = useState<Record<number, string>>({});
const refresh = useCallback(async () => {
try {
@ -29,64 +43,147 @@ export const IssuesWindow: React.FC<Props> = ({ id, zIndex }) => {
useEffect(() => { refresh(); }, [refresh]);
const addIssue = async () => {
if (!title.trim()) return;
await fetch('/api/issues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ title: title.trim(), description: desc.trim(), category }) });
setTitle(''); setDesc('');
const apiCall = async (url: string, opts: RequestInit) => {
await fetch(`/api${url}`, { ...opts, credentials: 'include', headers: { 'Content-Type': 'application/json', ...opts.headers } });
refresh();
};
const toggleResolve = async (issue: Issue) => {
await fetch(`/api/issues/${issue.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ resolved: !issue.resolved }) });
refresh();
const addIssue = async () => {
if (!title.trim()) return;
await apiCall('/issues', { method: 'POST', body: JSON.stringify({ title: title.trim(), description: desc.trim(), category }) });
setTitle(''); setDesc('');
};
const startEdit = (issue: Issue) => {
if (editingId === issue.id) { setEditingId(null); return; }
setEditingId(issue.id);
setEditTitle(issue.title);
setEditDesc(issue.description || '');
setEditCat(issue.category || 'other');
};
const saveEdit = async (issueId: number) => {
if (!editTitle.trim()) return;
await apiCall(`/issues/${issueId}`, { method: 'PATCH', body: JSON.stringify({ title: editTitle.trim(), description: editDesc.trim(), category: editCat }) });
setEditingId(null);
};
const addComment = async (issueId: number) => {
const text = (commentText[issueId] || '').trim();
if (!text) return;
await apiCall(`/issues/${issueId}/comments`, { method: 'POST', body: JSON.stringify({ text }) });
setCommentText(prev => ({ ...prev, [issueId]: '' }));
};
return (
<DraggableWindow id={id} title="Issues Board" zIndex={zIndex} width={540} height={520}>
{/* Issue list */}
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.75rem' }}>
{issues.length === 0 ? (
<div style={{ padding: 12, color: '#666', textAlign: 'center' }}>No issues</div>
) : issues.map(issue => (
<div key={issue.id} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f', borderRadius: 3,
border: '1px solid #333', opacity: issue.resolved ? 0.5 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
background: CAT_COLORS[issue.category] ?? '#888', color: '#111', fontWeight: 600 }}>
{issue.category}
</span>
<span style={{ flex: 1, fontWeight: 500 }}>{issue.title}</span>
<button onClick={() => toggleResolve(issue)} style={{ fontSize: '0.65rem', padding: '1px 6px',
background: issue.resolved ? '#333' : 'rgba(68,204,68,0.15)', color: issue.resolved ? '#888' : '#4c4',
border: '1px solid #444', borderRadius: 3, cursor: 'pointer' }}>
{issue.resolved ? '↻ Reopen' : '✓ Resolve'}
</button>
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.8rem' }}>
{issues.length === 0 && (
<div style={{ padding: 10, color: '#888', textAlign: 'center' }}>No open issues</div>
)}
{issues.map(issue => {
const cat = CATS[issue.category] || CATS.other;
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
const comments = issue.comments || [];
return (
<div key={issue.id} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f', borderRadius: 3, border: '1px solid #333', opacity: issue.resolved ? 0.55 : 1 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.65rem', padding: '1px 6px', borderRadius: 3, background: cat.color, color: '#fff', fontWeight: 600 }}>{cat.label}</span>
<strong style={{ fontSize: '0.8rem', flex: 1 }}>{issue.title}</strong>
<span style={{ fontSize: '0.65rem', color: '#888' }}>by {issue.author || 'User'}</span>
<span style={{ color: '#666', fontSize: '0.65rem' }}>{date}</span>
</div>
{/* Description */}
{issue.description && <div style={{ color: '#999', marginTop: 3, fontSize: '0.75rem' }}>{issue.description}</div>}
{/* Action buttons */}
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{issue.resolved ? (
<>
<button style={{ ...btnGray, fontSize: '0.65rem' }}
onClick={() => apiCall(`/issues/${issue.id}`, { method: 'PATCH', body: JSON.stringify({ resolved: false }) })}>
Reopen
</button>
<button style={{ ...btnGray, fontSize: '0.65rem', color: '#c66' }}
onClick={() => { if (confirm(`Delete issue "${issue.title}"?`)) apiCall(`/issues/${issue.id}`, { method: 'DELETE' }); }}>
🗑 Delete
</button>
</>
) : (
<button style={{ ...btnGray, fontSize: '0.65rem', background: 'rgba(68,204,68,0.15)', color: '#4c4', border: '1px solid rgba(68,204,68,0.3)' }}
onClick={() => apiCall(`/issues/${issue.id}`, { method: 'PATCH', body: JSON.stringify({ resolved: true }) })}>
Resolve
</button>
)}
<button style={{ ...btnGray, fontSize: '0.65rem' }} onClick={() => startEdit(issue)}> Edit</button>
</div>
{/* Inline edit form */}
{editingId === issue.id && (
<div style={{ marginTop: 4, padding: 4, background: '#222', borderRadius: 3 }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input value={editTitle} onChange={e => setEditTitle(e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<select value={editCat} onChange={e => setEditCat(e.target.value)} style={selectStyle}>
<option value="plugin">Plugin</option><option value="overlord">Overlord</option>
<option value="nav">Nav</option><option value="macro">Macro</option><option value="other">Other</option>
</select>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<textarea value={editDesc} onChange={e => setEditDesc(e.target.value)} rows={2}
style={{ ...inputStyle, flex: 1, fontSize: '0.75rem', resize: 'vertical' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<button style={{ ...btnBlue, fontSize: '0.7rem', padding: '3px 8px' }} onClick={() => saveEdit(issue.id)}>Save</button>
<button style={{ ...btnGray }} onClick={() => setEditingId(null)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Comments section */}
<div style={{ marginTop: 4, paddingTop: 4, borderTop: '1px solid #2a2a2a' }}>
{comments.length === 0 ? (
<div style={{ color: '#555', fontSize: '0.7rem', padding: '2px 0' }}>No comments yet</div>
) : (
comments.map(c => (
<div key={c.id} style={{ marginBottom: 3, fontSize: '0.72rem' }}>
<span style={{ color: '#8ac', fontWeight: 500 }}>{c.author || 'Anonymous'}</span>
<span style={{ color: '#555', marginLeft: 6, fontSize: '0.6rem' }}>
{c.created ? new Date(c.created).toLocaleDateString('sv-SE') : ''}
</span>
<div style={{ color: '#bbb', marginTop: 1 }}>{c.text}</div>
</div>
))
)}
{/* Add comment */}
<div style={{ display: 'flex', gap: 4, marginTop: 3 }}>
<input value={commentText[issue.id] || ''} onChange={e => setCommentText(prev => ({ ...prev, [issue.id]: e.target.value }))}
placeholder="Add a comment..." style={{ ...inputStyle, flex: 1, fontSize: '0.75rem' }}
onKeyDown={e => { if (e.key === 'Enter') addComment(issue.id); }} />
<button style={{ ...btnBlue, fontSize: '0.7rem', padding: '3px 8px' }} onClick={() => addComment(issue.id)}>Post</button>
</div>
</div>
</div>
{issue.description && <div style={{ color: '#888', marginTop: 3, fontSize: '0.7rem' }}>{issue.description}</div>}
<div style={{ color: '#555', fontSize: '0.6rem', marginTop: 2 }}>
by {issue.author} &middot; {new Date(issue.created).toLocaleDateString()}
</div>
</div>
))}
);
})}
</div>
{/* Add issue form */}
<div style={{ padding: 6, borderTop: '1px solid #333', display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', gap: 4 }}>
{/* Add issue form (bottom) */}
<div style={{ padding: 6, borderTop: '1px solid #333' }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Issue title..."
style={{ flex: 1, padding: '3px 6px', fontSize: '0.75rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3 }} />
<select value={category} onChange={e => setCategory(e.target.value)}
style={{ padding: '3px 4px', fontSize: '0.7rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3 }}>
style={{ ...inputStyle, flex: 1 }} onKeyDown={e => { if (e.key === 'Enter') addIssue(); }} />
<select value={category} onChange={e => setCategory(e.target.value)} style={selectStyle}>
<option value="plugin">Plugin</option><option value="overlord">Overlord</option>
<option value="nav">Nav</option><option value="macro">Macro</option><option value="other">Other</option>
</select>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<textarea value={desc} onChange={e => setDesc(e.target.value)} placeholder="Description..."
rows={2} style={{ flex: 1, padding: '3px 6px', fontSize: '0.7rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3, resize: 'vertical' }} />
<button onClick={addIssue} style={{ padding: '4px 12px', background: 'rgba(68,136,255,0.15)', color: '#6aadff',
border: '1px solid rgba(68,136,255,0.3)', borderRadius: 3, cursor: 'pointer', alignSelf: 'flex-end', fontSize: '0.7rem' }}>Add</button>
<textarea value={desc} onChange={e => setDesc(e.target.value)} placeholder="Description (optional)..."
rows={2} style={{ ...inputStyle, flex: 1, fontSize: '0.75rem', resize: 'vertical' }} />
<button style={{ ...btnBlue, alignSelf: 'flex-end' }} onClick={addIssue}>Add</button>
</div>
</div>
</DraggableWindow>