MosswartOverlord/frontend/src/components/windows/IssuesWindow.tsx
Erik 851fc5f7cd 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>
2026-04-12 22:41:13 +02:00

191 lines
10 KiB
TypeScript

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?: Comment[];
}
interface Props { id: string; zIndex: number; }
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 {
const data = await apiFetch<{ issues: Issue[] }>('/issues');
setIssues((data.issues ?? []).sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0)));
} catch { /* ignore */ }
}, []);
useEffect(() => { refresh(); }, [refresh]);
const apiCall = async (url: string, opts: RequestInit) => {
await fetch(`/api${url}`, { ...opts, credentials: 'include', headers: { 'Content-Type': 'application/json', ...opts.headers } });
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.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>
);
})}
</div>
{/* 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={{ ...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 (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>
);
};