chore : move all to root
This commit is contained in:
248
nest-front/src/pages/public/AccountPage.tsx
Normal file
248
nest-front/src/pages/public/AccountPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type Tab = 'profile' | 'threads' | 'bugs' | 'password';
|
||||
|
||||
export default function AccountPage() {
|
||||
const { user, updateUsername } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||
|
||||
const userThreads = MOCK_THREADS.filter((t) => t.authorId === user?.id);
|
||||
const userBugs = MOCK_BUGS.filter((b) => b.submittedById === user?.id);
|
||||
|
||||
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) => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!username.trim()) { setError('Username cannot be empty.'); return; }
|
||||
if (username.length < 3) { setError('Must be at least 3 characters.'); return; }
|
||||
updateUsername(username.trim());
|
||||
setEditing(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}, [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);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
setLoading(false);
|
||||
setForm({ current: '', next: '', confirm: '' });
|
||||
setErrors({ success: 'Password changed successfully.' });
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user