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:
parent
b00c386d77
commit
851fc5f7cd
4 changed files with 264 additions and 167 deletions
|
|
@ -2,23 +2,37 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { DraggableWindow } from './DraggableWindow';
|
import { DraggableWindow } from './DraggableWindow';
|
||||||
import { apiFetch } from '../../api/client';
|
import { apiFetch } from '../../api/client';
|
||||||
|
|
||||||
|
interface Comment { id: number; text: string; author: string; created: string; }
|
||||||
interface Issue {
|
interface Issue {
|
||||||
id: number; title: string; description: string; category: string;
|
id: number; title: string; description: string; category: string;
|
||||||
created: string; resolved: boolean; author: string;
|
created: string; resolved: boolean; author: string; comments?: Comment[];
|
||||||
comments?: Array<{ id: number; text: string; author: string; created: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props { id: string; zIndex: number; }
|
interface Props { id: string; zIndex: number; }
|
||||||
|
|
||||||
const CAT_COLORS: Record<string, string> = {
|
const CATS: Record<string, { label: string; color: string }> = {
|
||||||
plugin: '#4488ff', overlord: '#44cc44', nav: '#ffaa00', macro: '#cc44cc', other: '#888',
|
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 }) => {
|
export const IssuesWindow: React.FC<Props> = ({ id, zIndex }) => {
|
||||||
const [issues, setIssues] = useState<Issue[]>([]);
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [desc, setDesc] = useState('');
|
const [desc, setDesc] = useState('');
|
||||||
const [category, setCategory] = useState('plugin');
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -29,64 +43,147 @@ export const IssuesWindow: React.FC<Props> = ({ id, zIndex }) => {
|
||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addIssue = async () => {
|
const apiCall = async (url: string, opts: RequestInit) => {
|
||||||
if (!title.trim()) return;
|
await fetch(`/api${url}`, { ...opts, credentials: 'include', headers: { 'Content-Type': 'application/json', ...opts.headers } });
|
||||||
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('');
|
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleResolve = async (issue: Issue) => {
|
const addIssue = async () => {
|
||||||
await fetch(`/api/issues/${issue.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
if (!title.trim()) return;
|
||||||
body: JSON.stringify({ resolved: !issue.resolved }) });
|
await apiCall('/issues', { method: 'POST', body: JSON.stringify({ title: title.trim(), description: desc.trim(), category }) });
|
||||||
refresh();
|
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 (
|
return (
|
||||||
<DraggableWindow id={id} title="Issues Board" zIndex={zIndex} width={540} height={520}>
|
<DraggableWindow id={id} title="Issues Board" zIndex={zIndex} width={540} height={520}>
|
||||||
{/* Issue list */}
|
{/* Issue list */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.75rem' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.8rem' }}>
|
||||||
{issues.length === 0 ? (
|
{issues.length === 0 && (
|
||||||
<div style={{ padding: 12, color: '#666', textAlign: 'center' }}>No issues</div>
|
<div style={{ padding: 10, color: '#888', textAlign: 'center' }}>No open issues</div>
|
||||||
) : issues.map(issue => (
|
)}
|
||||||
<div key={issue.id} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f', borderRadius: 3,
|
{issues.map(issue => {
|
||||||
border: '1px solid #333', opacity: issue.resolved ? 0.5 : 1 }}>
|
const cat = CATS[issue.category] || CATS.other;
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
|
||||||
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
|
const comments = issue.comments || [];
|
||||||
background: CAT_COLORS[issue.category] ?? '#888', color: '#111', fontWeight: 600 }}>
|
return (
|
||||||
{issue.category}
|
<div key={issue.id} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f', borderRadius: 3, border: '1px solid #333', opacity: issue.resolved ? 0.55 : 1 }}>
|
||||||
</span>
|
{/* Header */}
|
||||||
<span style={{ flex: 1, fontWeight: 500 }}>{issue.title}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<button onClick={() => toggleResolve(issue)} style={{ fontSize: '0.65rem', padding: '1px 6px',
|
<span style={{ fontSize: '0.65rem', padding: '1px 6px', borderRadius: 3, background: cat.color, color: '#fff', fontWeight: 600 }}>{cat.label}</span>
|
||||||
background: issue.resolved ? '#333' : 'rgba(68,204,68,0.15)', color: issue.resolved ? '#888' : '#4c4',
|
<strong style={{ fontSize: '0.8rem', flex: 1 }}>{issue.title}</strong>
|
||||||
border: '1px solid #444', borderRadius: 3, cursor: 'pointer' }}>
|
<span style={{ fontSize: '0.65rem', color: '#888' }}>by {issue.author || 'User'}</span>
|
||||||
{issue.resolved ? '↻ Reopen' : '✓ Resolve'}
|
<span style={{ color: '#666', fontSize: '0.65rem' }}>{date}</span>
|
||||||
</button>
|
</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>
|
||||||
{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} · {new Date(issue.created).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Add issue form */}
|
|
||||||
<div style={{ padding: 6, borderTop: '1px solid #333', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
{/* Add issue form (bottom) */}
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<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..."
|
<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 }} />
|
style={{ ...inputStyle, flex: 1 }} onKeyDown={e => { if (e.key === 'Enter') addIssue(); }} />
|
||||||
<select value={category} onChange={e => setCategory(e.target.value)}
|
<select value={category} onChange={e => setCategory(e.target.value)} style={selectStyle}>
|
||||||
style={{ padding: '3px 4px', fontSize: '0.7rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3 }}>
|
|
||||||
<option value="plugin">Plugin</option><option value="overlord">Overlord</option>
|
<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>
|
<option value="nav">Nav</option><option value="macro">Macro</option><option value="other">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} placeholder="Description..."
|
<textarea value={desc} onChange={e => setDesc(e.target.value)} placeholder="Description (optional)..."
|
||||||
rows={2} style={{ flex: 1, padding: '3px 6px', fontSize: '0.7rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3, resize: 'vertical' }} />
|
rows={2} style={{ ...inputStyle, flex: 1, fontSize: '0.75rem', resize: 'vertical' }} />
|
||||||
<button onClick={addIssue} style={{ padding: '4px 12px', background: 'rgba(68,136,255,0.15)', color: '#6aadff',
|
<button style={{ ...btnBlue, alignSelf: 'flex-end' }} onClick={addIssue}>Add</button>
|
||||||
border: '1px solid rgba(68,136,255,0.3)', borderRadius: 3, cursor: 'pointer', alignSelf: 'flex-end', fontSize: '0.7rem' }}>Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DraggableWindow>
|
</DraggableWindow>
|
||||||
|
|
|
||||||
120
static/v2/assets/index-B6P2bla9.js
Normal file
120
static/v2/assets/index-B6P2bla9.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,7 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Mosswart Overlord v2</title>
|
<title>Mosswart Overlord v2</title>
|
||||||
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||||
<script type="module" crossorigin src="/v2/assets/index-CWdy6tT9.js"></script>
|
<script type="module" crossorigin src="/v2/assets/index-B6P2bla9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css">
|
<link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue