270 lines
13 KiB
TypeScript
270 lines
13 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { bugsApi, forumApi, usersApi } from '../../utils/api';
|
|
import { formatDate } from '../../utils/format';
|
|
import { Link } from 'react-router-dom';
|
|
import type { BugReport, ForumThread } from '../../types';
|
|
|
|
type Tab = 'profile' | 'threads' | 'bugs' | 'password';
|
|
|
|
export default function AccountPage() {
|
|
const { user, updateUsername } = useAuth();
|
|
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
|
const [userThreads, setUserThreads] = useState<ForumThread[]>([]);
|
|
const [userBugs, setUserBugs] = useState<BugReport[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
forumApi.getThreads({ limit: 200 })
|
|
.then((res) => setUserThreads(res.data.filter((t) => t.authorId === user.id)))
|
|
.catch(() => setUserThreads([]));
|
|
|
|
bugsApi.getBugs({ limit: 200 })
|
|
.then((res) => setUserBugs(res.data.filter((b) => b.submittedById === user.id)))
|
|
.catch(() => setUserBugs([]));
|
|
}, [user]);
|
|
|
|
const tabs: { id: Tab; label: string }[] = [
|
|
{ id: 'profile', label: 'Profile' },
|
|
{ id: 'threads', label: `Threads (${userThreads.length})` },
|
|
{ id: 'bugs', label: `Bug Reports (${userBugs.length})` },
|
|
{ id: 'password', label: 'Change Password' },
|
|
];
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '4rem 1.5rem' }}>
|
|
<div className="section-label">My Account</div>
|
|
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: 'clamp(2rem, 5vw, 3rem)', marginTop: '0.5rem', marginBottom: '2rem' }}>
|
|
{user.username}
|
|
</h1>
|
|
|
|
{/* Tabs */}
|
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '2rem', gap: '0', flexWrap: 'wrap' }}>
|
|
{tabs.map(({ id, label }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => setActiveTab(id)}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderBottom: activeTab === id ? '2px solid var(--color-green)' : '2px solid transparent',
|
|
color: activeTab === id ? 'var(--color-green)' : 'var(--color-text-muted)',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.78rem',
|
|
padding: '0.6rem 1rem',
|
|
cursor: 'pointer',
|
|
letterSpacing: '0.05em',
|
|
textTransform: 'uppercase',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Profile Tab */}
|
|
{activeTab === 'profile' && (
|
|
<ProfileTab user={user} updateUsername={updateUsername} />
|
|
)}
|
|
|
|
{/* Threads Tab */}
|
|
{activeTab === 'threads' && (
|
|
<div>
|
|
{userThreads.length === 0 ? (
|
|
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
|
|
You haven't posted any threads yet.{' '}
|
|
<Link to="/forum" style={{ color: 'var(--color-green)' }}>Go to Forum</Link>
|
|
</div>
|
|
) : (
|
|
userThreads.map((t) => (
|
|
<div key={t.id} className="crt-box" style={{ padding: '1rem 1.25rem', marginBottom: '0.5rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<Link to={`/forum/thread/${t.id}`} style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>
|
|
{t.title}
|
|
</Link>
|
|
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flexShrink: 0 }}>
|
|
{formatDate(t.createdAt)}
|
|
</span>
|
|
</div>
|
|
<div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginTop: '0.25rem' }}>
|
|
{t.categoryName} — {t.replyCount} replies
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bug Reports Tab */}
|
|
{activeTab === 'bugs' && (
|
|
<div>
|
|
{userBugs.length === 0 ? (
|
|
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
|
|
You haven't submitted any bug reports.{' '}
|
|
<Link to="/bugs" style={{ color: 'var(--color-green)' }}>Report a Bug</Link>
|
|
</div>
|
|
) : (
|
|
userBugs.map((b) => (
|
|
<div key={b.id} className="crt-box" style={{ padding: '1rem 1.25rem', marginBottom: '0.5rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>{b.title}</span>
|
|
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flexShrink: 0 }}>{formatDate(b.createdAt)}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>{b.uniqueCode}</span>
|
|
<span className={`badge badge-${b.status === 'in_progress' ? 'progress' : b.status}`}>{b.status}</span>
|
|
<span className={`badge badge-${b.severity}`}>{b.severity}</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Password Tab */}
|
|
{activeTab === 'password' && <ChangePasswordForm />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Profile Tab ────────────────────────────────────────────────────────────────
|
|
|
|
function ProfileTab({ user, updateUsername }: { user: NonNullable<ReturnType<typeof useAuth>['user']>; updateUsername: (u: string) => Promise<{ success: boolean; error?: string }> }) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [username, setUsername] = useState(user.username);
|
|
const [error, setError] = useState('');
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!username.trim()) { setError('Username cannot be empty.'); return; }
|
|
if (username.length < 3) { setError('Must be at least 3 characters.'); return; }
|
|
const result = await updateUsername(username.trim());
|
|
if (result.success) {
|
|
setEditing(false);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} else {
|
|
setError(result.error ?? 'Failed to update username.');
|
|
}
|
|
}, [username, updateUsername]);
|
|
|
|
return (
|
|
<div className="crt-box" style={{ padding: '2rem' }}>
|
|
{saved && (
|
|
<div style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', marginBottom: '1rem', background: 'rgba(0,255,65,0.07)', border: '1px solid rgba(0,255,65,0.2)', padding: '0.6rem 0.75rem' }}>
|
|
[OK] Username updated successfully.
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
{/* Username */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '0.5rem', alignItems: 'center' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>USERNAME</span>
|
|
{editing ? (
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<input
|
|
className={`input-terminal${error ? ' error' : ''}`}
|
|
value={username}
|
|
onChange={(e) => { setUsername(e.target.value); setError(''); }}
|
|
style={{ flex: 1 }}
|
|
autoFocus
|
|
/>
|
|
<button className="btn-terminal" onClick={handleSave} style={{ padding: '0.35rem 0.75rem', fontSize: '0.75rem' }}>Save</button>
|
|
<button className="btn-terminal btn-danger" onClick={() => { setEditing(false); setUsername(user.username); setError(''); }} style={{ padding: '0.35rem 0.75rem', fontSize: '0.75rem' }}>Cancel</button>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>{user.username}</span>
|
|
<button
|
|
className="btn-terminal"
|
|
onClick={() => setEditing(true)}
|
|
style={{ padding: '0.2rem 0.6rem', fontSize: '0.65rem' }}
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{error && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', gridColumn: '2' }}>{error}</div>}
|
|
|
|
{/* Static fields */}
|
|
{[
|
|
{ label: 'EMAIL', value: user.email },
|
|
{ label: 'ROLE', value: user.role.toUpperCase() },
|
|
{ label: 'MEMBER SINCE', value: formatDate(user.createdAt) },
|
|
{ label: 'ADMIN', value: user.isAdmin ? 'Yes' : 'No' },
|
|
].map(({ label, value }) => (
|
|
<div key={label} style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '0.5rem', alignItems: 'center' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>{label}</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.87rem' }}>{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Change Password Form ───────────────────────────────────────────────────────
|
|
|
|
function ChangePasswordForm() {
|
|
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
|
|
const [errors, setErrors] = useState<Partial<typeof form & { success: string }>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const next: typeof errors = {};
|
|
if (!form.current) next.current = 'Current password required.';
|
|
if (!form.next) next.next = 'New password required.';
|
|
else if (form.next.length < 8) next.next = 'Must be at least 8 characters.';
|
|
if (form.next !== form.confirm) next.confirm = 'Passwords do not match.';
|
|
setErrors(next);
|
|
if (Object.keys(next).length > 0) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
await usersApi.changePassword(form.current, form.next);
|
|
setForm({ current: '', next: '', confirm: '' });
|
|
setErrors({ success: 'Password changed successfully.' });
|
|
} catch (err) {
|
|
setErrors({ current: err instanceof Error ? err.message : 'Failed to change password.' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [form]);
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
|
|
{errors.success && (
|
|
<div style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', marginBottom: '1.25rem', background: 'rgba(0,255,65,0.07)', border: '1px solid rgba(0,255,65,0.2)', padding: '0.6rem 0.75rem' }}>
|
|
[OK] {errors.success}
|
|
</div>
|
|
)}
|
|
{[
|
|
{ key: 'current' as const, label: 'Current Password', auto: 'current-password' },
|
|
{ key: 'next' as const, label: 'New Password', auto: 'new-password' },
|
|
{ key: 'confirm' as const, label: 'Confirm New Password', auto: 'new-password' },
|
|
].map(({ key, label, auto }) => (
|
|
<div key={key} style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>{label}</label>
|
|
<input
|
|
className={`input-terminal${errors[key] ? ' error' : ''}`}
|
|
type="password"
|
|
autoComplete={auto}
|
|
value={form[key]}
|
|
onChange={(e) => { setForm((p) => ({ ...p, [key]: e.target.value })); setErrors((p) => ({ ...p, [key]: undefined })); }}
|
|
/>
|
|
{errors[key] && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors[key]}</div>}
|
|
</div>
|
|
))}
|
|
<button type="submit" className="btn-terminal" disabled={loading} style={{ marginTop: '0.5rem', opacity: loading ? 0.7 : 1 }}>
|
|
{loading ? 'Saving...' : '> Update Password'}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|