363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
import { Link, Navigate, useParams } from 'react-router-dom';
|
|
import { bugsApi, ApiError } from '../../utils/api';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { formatDate, formatDateTime } from '../../utils/format';
|
|
import type { BugComment, BugReport, BugSeverity, BugStatus } from '../../types';
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
function StatusBadge({ status }: { status: BugStatus }) {
|
|
const map: Record<BugStatus, string> = {
|
|
open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed',
|
|
};
|
|
const labels: Record<BugStatus, string> = {
|
|
open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed',
|
|
};
|
|
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
|
|
}
|
|
|
|
function SeverityBadge({ severity }: { severity: BugSeverity }) {
|
|
const map: Record<BugSeverity, string> = {
|
|
low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical',
|
|
};
|
|
return <span className={`badge ${map[severity]}`}>{severity}</span>;
|
|
}
|
|
|
|
// ── Comment component ──────────────────────────────────────────────────────────
|
|
|
|
function CommentItem({ comment }: { comment: BugComment }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '0.9rem 1.1rem',
|
|
marginBottom: '0.5rem',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.78rem' }}>
|
|
{comment.authorName}
|
|
</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', flexShrink: 0 }}>
|
|
{formatDateTime(comment.createdAt)}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.83rem', lineHeight: 1.75 }}>
|
|
{comment.content}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Bug Detail Page ────────────────────────────────────────────────────────────
|
|
|
|
export default function BugDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { user, isAuthenticated } = useAuth();
|
|
|
|
const [bug, setBug] = useState<BugReport | null>(null);
|
|
const [comments, setComments] = useState<BugComment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [notFound, setNotFound] = useState(false);
|
|
|
|
const [newComment, setNewComment] = useState('');
|
|
const [commentError, setCommentError] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
|
|
bugsApi.getBug(id)
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
const { comments: bugComments, ...bugData } = data;
|
|
setBug(bugData);
|
|
setComments(bugComments.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
if (err instanceof ApiError && err.status === 404) setNotFound(true);
|
|
})
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|
|
|
return () => { cancelled = true; };
|
|
}, [id]);
|
|
|
|
const alreadyVoted = useMemo(
|
|
() => !!user && !!bug && (bug.meTooBugs ?? []).includes(user.id),
|
|
[user, bug]
|
|
);
|
|
const isOwnReport = useMemo(
|
|
() => !!user && !!bug && bug.submittedById === user.id,
|
|
[user, bug]
|
|
);
|
|
|
|
const handleMeToo = useCallback(async () => {
|
|
if (!user || !bug || alreadyVoted || isOwnReport) return;
|
|
try {
|
|
await bugsApi.toggleMeToo(bug.id);
|
|
setBug((prev) => prev ? { ...prev, meTooBugs: [...(prev.meTooBugs ?? []), user.id] } : prev);
|
|
} catch {
|
|
// silently ignore
|
|
}
|
|
}, [user, bug, alreadyVoted, isOwnReport]);
|
|
|
|
const handleComment = useCallback(async () => {
|
|
if (!user || !bug) return;
|
|
if (!newComment.trim()) { setCommentError('Comment cannot be empty.'); return; }
|
|
if (newComment.trim().length < 5) { setCommentError('Comment must be at least 5 characters.'); return; }
|
|
|
|
setCommentError('');
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
const comment = await bugsApi.addComment(bug.id, newComment.trim());
|
|
setComments((prev) => [...prev, comment]);
|
|
setNewComment('');
|
|
} catch (err) {
|
|
setCommentError(err instanceof Error ? err.message : 'Failed to post comment.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [user, bug, newComment]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
|
|
Loading...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (notFound || !bug) {
|
|
return <Navigate to="/bugs" replace />;
|
|
}
|
|
|
|
const metooCount = (bug.meTooBugs ?? []).length;
|
|
|
|
return (
|
|
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}>
|
|
{/* Breadcrumb */}
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>
|
|
<Link to="/bugs" style={{ color: 'var(--color-cyan)' }}>Bug Reports</Link>
|
|
{' '}>{' '}
|
|
<span style={{ color: 'var(--color-text-dim)' }}>{bug.uniqueCode}</span>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="crt-box" style={{ padding: '1.75rem', marginBottom: '1rem' }}>
|
|
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.75rem' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem' }}>
|
|
{bug.uniqueCode}
|
|
</span>
|
|
<StatusBadge status={bug.status} />
|
|
<SeverityBadge severity={bug.severity} />
|
|
</div>
|
|
|
|
<h1
|
|
style={{
|
|
fontFamily: 'var(--font-heading)',
|
|
color: 'var(--color-text)',
|
|
fontSize: 'clamp(1.4rem, 4vw, 2rem)',
|
|
marginBottom: '1.25rem',
|
|
}}
|
|
>
|
|
{bug.title}
|
|
</h1>
|
|
|
|
{/* Meta grid */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.6rem', marginBottom: '1.5rem' }}>
|
|
{[
|
|
{ label: 'Submitted by', value: bug.submittedByName },
|
|
{ label: 'Date', value: formatDate(bug.createdAt) },
|
|
{ label: 'Game Version', value: `v${bug.gameVersion}` },
|
|
{ label: 'Assigned to', value: bug.assignedToName ?? 'Unassigned' },
|
|
].map(({ label, value }) => (
|
|
<div
|
|
key={label}
|
|
style={{
|
|
background: 'var(--color-surface-alt)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '0.55rem 0.7rem',
|
|
}}
|
|
>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: '0.2rem' }}>
|
|
{label}
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.8rem' }}>{value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<div className="section-label" style={{ marginBottom: '0.4rem' }}>Description</div>
|
|
<div
|
|
style={{
|
|
background: 'var(--color-surface-alt)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '0.9rem 1rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-dim)',
|
|
fontSize: '0.83rem',
|
|
lineHeight: 1.8,
|
|
whiteSpace: 'pre-wrap',
|
|
borderRadius: '6px',
|
|
}}
|
|
>
|
|
{bug.description}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Steps to reproduce */}
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<div className="section-label" style={{ marginBottom: '0.4rem' }}>Steps to Reproduce</div>
|
|
<div
|
|
style={{
|
|
background: 'var(--color-surface-alt)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '0.9rem 1rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-dim)',
|
|
fontSize: '0.83rem',
|
|
lineHeight: 1.8,
|
|
whiteSpace: 'pre-wrap',
|
|
borderRadius: '6px',
|
|
}}
|
|
>
|
|
{bug.stepsToReproduce}
|
|
</div>
|
|
</div>
|
|
|
|
{/* "I have this too" section */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '1rem',
|
|
flexWrap: 'wrap',
|
|
padding: '0.9rem 1rem',
|
|
background: 'rgba(5,150,105,0.05)',
|
|
border: '1px solid rgba(5,150,105,0.2)',
|
|
borderRadius: '6px',
|
|
}}
|
|
>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-green)', fontSize: '0.82rem' }}>
|
|
<span style={{ fontSize: '1.1rem' }}>{metooCount}</span>{' '}
|
|
{metooCount === 1 ? 'user has' : 'users have'} this issue
|
|
</div>
|
|
|
|
{!isAuthenticated ? (
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
|
|
<Link to="/login">Login</Link> to confirm you have this issue
|
|
</div>
|
|
) : isOwnReport ? (
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
|
|
(this is your report)
|
|
</span>
|
|
) : alreadyVoted ? (
|
|
<div
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.78rem',
|
|
color: 'var(--color-green)',
|
|
border: '1px solid var(--color-green)',
|
|
padding: '0.25rem 0.75rem',
|
|
background: 'rgba(5,150,105,0.08)',
|
|
cursor: 'default',
|
|
borderRadius: '4px',
|
|
}}
|
|
>
|
|
✓ You reported this too
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="btn-terminal"
|
|
onClick={handleMeToo}
|
|
style={{ fontSize: '0.78rem', padding: '0.3rem 0.9rem' }}
|
|
>
|
|
▶ I have this too
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comments section */}
|
|
<div style={{ marginTop: '2rem' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.75rem',
|
|
borderBottom: '2px solid var(--color-border)',
|
|
paddingBottom: '0.5rem',
|
|
marginBottom: '1rem',
|
|
}}
|
|
>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
|
|
Discussion
|
|
</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}>
|
|
{comments.length}
|
|
</span>
|
|
</div>
|
|
|
|
{comments.length === 0 ? (
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem', padding: '1rem 0' }}>
|
|
No comments yet. Be the first to comment.
|
|
</div>
|
|
) : (
|
|
comments.map((comment) => (
|
|
<CommentItem key={comment.id} comment={comment} />
|
|
))
|
|
)}
|
|
|
|
<div style={{ marginTop: '1.25rem' }}>
|
|
{isAuthenticated ? (
|
|
<div className="crt-box" style={{ padding: '1.25rem' }}>
|
|
<div className="section-label" style={{ marginBottom: '0.6rem' }}>Add a Comment</div>
|
|
<textarea
|
|
className={`input-terminal${commentError ? ' error' : ''}`}
|
|
rows={4}
|
|
placeholder="Write your comment..."
|
|
value={newComment}
|
|
onChange={(e) => { setNewComment(e.target.value); setCommentError(''); }}
|
|
style={{ resize: 'vertical', marginBottom: '0.6rem' }}
|
|
disabled={submitting}
|
|
aria-label="Comment text"
|
|
/>
|
|
{commentError && (
|
|
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginBottom: '0.6rem' }}>
|
|
{commentError}
|
|
</div>
|
|
)}
|
|
<button
|
|
className="btn-terminal"
|
|
onClick={handleComment}
|
|
disabled={submitting}
|
|
style={{ opacity: submitting ? 0.6 : 1 }}
|
|
>
|
|
{submitting ? 'Posting...' : '▶ Post Comment'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="crt-box" style={{ padding: '1.25rem', textAlign: 'center' }}>
|
|
<p style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
|
You must be logged in to comment.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'center' }}>
|
|
<Link to="/login" className="btn-terminal">Login</Link>
|
|
<Link to="/register" className="btn-terminal btn-amber">Register</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|