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>
191 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|