189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { MOCK_CATEGORIES, MOCK_THREADS } from '../../data/mockData';
|
|
import { timeAgo } from '../../utils/format';
|
|
import type { ForumCategory, ForumThread } from '../../types';
|
|
|
|
// ── Sub-components ─────────────────────────────────────────────────────────────
|
|
|
|
function CategoryCard({ category, threads }: { category: ForumCategory; threads: ForumThread[] }) {
|
|
const pinned = threads.filter((t) => t.isPinned && t.categoryId === category.id);
|
|
const regular = threads.filter((t) => !t.isPinned && t.categoryId === category.id);
|
|
const categoryThreads = [...pinned, ...regular];
|
|
|
|
return (
|
|
<section className="crt-box" style={{ marginBottom: '1.5rem' }}>
|
|
{/* Category header */}
|
|
<div
|
|
style={{
|
|
padding: '1rem 1.5rem',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: '1rem',
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<span
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-green)',
|
|
fontSize: '0.75rem',
|
|
opacity: 0.7,
|
|
}}
|
|
>
|
|
{category.icon}
|
|
</span>
|
|
<div>
|
|
<h2
|
|
style={{
|
|
fontFamily: 'var(--font-heading)',
|
|
color: 'var(--color-text)',
|
|
fontSize: '1.1rem',
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{category.name}
|
|
</h2>
|
|
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', margin: 0 }}>
|
|
{category.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: '0.72rem',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
<span style={{ color: 'var(--color-green)' }}>{category.threadCount}</span> threads
|
|
</div>
|
|
</div>
|
|
|
|
{/* Threads */}
|
|
<div>
|
|
{categoryThreads.length === 0 ? (
|
|
<div style={{ padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: '0.8rem', textAlign: 'center' }}>
|
|
No threads yet. Be the first to post.
|
|
</div>
|
|
) : (
|
|
categoryThreads.map((thread, idx) => (
|
|
<div
|
|
key={thread.id}
|
|
style={{
|
|
padding: '0.85rem 1.5rem',
|
|
borderBottom: idx < categoryThreads.length - 1 ? '1px solid var(--color-border)' : 'none',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: '1rem',
|
|
flexWrap: 'wrap',
|
|
background: thread.isPinned ? 'rgba(217,119,6,0.05)' : 'transparent',
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: '0' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
|
|
{thread.isPinned && (
|
|
<span className="badge badge-progress">Pinned</span>
|
|
)}
|
|
{thread.isLocked && (
|
|
<span className="badge badge-closed">Locked</span>
|
|
)}
|
|
<Link
|
|
to={`/forum/thread/${thread.id}`}
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text)',
|
|
fontSize: '0.87rem',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{thread.title}
|
|
</Link>
|
|
</div>
|
|
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
|
|
by <span style={{ color: 'var(--color-text-dim)' }}>{thread.authorName}</span>
|
|
{' '}— {timeAgo(thread.createdAt)}
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
|
<div style={{ color: 'var(--color-green)' }}>{thread.replyCount}</div>
|
|
<div>replies</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Forum Page ─────────────────────────────────────────────────────────────────
|
|
|
|
export default function ForumPage() {
|
|
const [search, setSearch] = useState('');
|
|
|
|
const filteredCategories = useMemo(() => {
|
|
if (!search.trim()) return MOCK_CATEGORIES;
|
|
const q = search.toLowerCase();
|
|
return MOCK_CATEGORIES.filter((cat) =>
|
|
cat.name.toLowerCase().includes(q) ||
|
|
MOCK_THREADS.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q))
|
|
);
|
|
}, [search]);
|
|
|
|
return (
|
|
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: '2.5rem', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1.5rem' }}>
|
|
<div>
|
|
<div className="section-label">Community</div>
|
|
<h1
|
|
style={{
|
|
fontFamily: 'var(--font-heading)',
|
|
color: 'var(--color-text)',
|
|
fontSize: 'clamp(2rem, 5vw, 3rem)',
|
|
marginTop: '0.5rem',
|
|
}}
|
|
>
|
|
FORUM
|
|
</h1>
|
|
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginTop: '0.5rem', fontFamily: 'var(--font-mono)' }}>
|
|
Read freely. Login to post.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
<input
|
|
className="input-terminal"
|
|
type="search"
|
|
placeholder="Search threads..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
style={{ width: '220px' }}
|
|
aria-label="Search forum threads"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Categories */}
|
|
{filteredCategories.length === 0 ? (
|
|
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
|
|
No results found for "{search}"
|
|
</div>
|
|
) : (
|
|
filteredCategories.map((cat) => (
|
|
<CategoryCard key={cat.id} category={cat} threads={MOCK_THREADS} />
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|