This repository has been archived on 2026-05-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Nest/nest-front/src/pages/public/ForumPage.tsx

248 lines
9.0 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { forumApi, settingsApi } from '../../utils/api';
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>
{' '}&mdash; {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 [categories, setCategories] = useState<ForumCategory[]>([]);
const [threads, setThreads] = useState<ForumThread[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [forumEnabled, setForumEnabled] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
settingsApi.get(),
forumApi.getCategories(),
forumApi.getThreads({ limit: 200 }),
])
.then(([settings, cats, threadRes]) => {
if (cancelled) return;
setForumEnabled(settings.forumEnabled);
setCategories(cats);
setThreads(threadRes.data);
})
.catch(() => {
if (cancelled) return;
setError('Failed to load forum. Please try again.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const filteredCategories = useMemo(() => {
if (!search.trim()) return categories;
const q = search.toLowerCase();
return categories.filter((cat) =>
cat.name.toLowerCase().includes(q) ||
threads.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q))
);
}, [search, categories, threads]);
if (!loading && !forumEnabled) {
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem', textAlign: 'center' }}>
<div className="section-label">Community</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: 'clamp(2rem, 5vw, 3rem)', marginTop: '0.5rem' }}>
FORUM UNAVAILABLE
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginTop: '1rem', fontFamily: 'var(--font-mono)' }}>
The forum has been temporarily disabled by an administrator.
</p>
</div>
);
}
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>
{loading && (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
Loading forum...
</div>
)}
{error && !loading && (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-red)', fontFamily: 'var(--font-mono)' }}>
{error}
</div>
)}
{/* Categories */}
{!loading && !error && (
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={threads} />
))
)
)}
</div>
);
}