304 lines
13 KiB
JavaScript
304 lines
13 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Container } from 'react-bootstrap'
|
|
import { Link } from 'react-router-dom'
|
|
import { fetchPortalSummary } from '../api/client'
|
|
import PortalTopicRow from '../components/PortalTopicRow'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useAuth } from '../context/AuthContext'
|
|
|
|
export default function Home() {
|
|
const [forums, setForums] = useState([])
|
|
const [threads, setThreads] = useState([])
|
|
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
|
|
const [error, setError] = useState('')
|
|
const [loadingForums, setLoadingForums] = useState(true)
|
|
const [loadingThreads, setLoadingThreads] = useState(true)
|
|
const [loadingStats, setLoadingStats] = useState(true)
|
|
const [profile, setProfile] = useState(null)
|
|
const { token, roles, email } = useAuth()
|
|
const { t } = useTranslation()
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
setLoadingForums(true)
|
|
setLoadingThreads(true)
|
|
setLoadingStats(true)
|
|
setError('')
|
|
|
|
fetchPortalSummary()
|
|
.then((data) => {
|
|
if (!active) return
|
|
setForums(data?.forums || [])
|
|
setThreads(data?.threads || [])
|
|
setStats({
|
|
threads: data?.stats?.threads ?? 0,
|
|
posts: data?.stats?.posts ?? 0,
|
|
users: data?.stats?.users ?? 0,
|
|
})
|
|
setProfile(data?.profile || null)
|
|
})
|
|
.catch((err) => {
|
|
if (!active) return
|
|
setError(err.message)
|
|
setForums([])
|
|
setThreads([])
|
|
setStats({ threads: 0, posts: 0, users: 0 })
|
|
setProfile(null)
|
|
})
|
|
.finally(() => {
|
|
if (!active) return
|
|
setLoadingForums(false)
|
|
setLoadingThreads(false)
|
|
setLoadingStats(false)
|
|
})
|
|
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [token])
|
|
|
|
const getParentId = (forum) => {
|
|
if (!forum.parent) return null
|
|
if (typeof forum.parent === 'string') {
|
|
return forum.parent.split('/').pop()
|
|
}
|
|
return forum.parent.id ?? null
|
|
}
|
|
|
|
const forumTree = useMemo(() => {
|
|
const map = new Map()
|
|
const roots = []
|
|
|
|
forums.forEach((forum) => {
|
|
map.set(String(forum.id), { ...forum, children: [] })
|
|
})
|
|
|
|
forums.forEach((forum) => {
|
|
const parentId = getParentId(forum)
|
|
const node = map.get(String(forum.id))
|
|
if (parentId && map.has(String(parentId))) {
|
|
map.get(String(parentId)).children.push(node)
|
|
} else {
|
|
roots.push(node)
|
|
}
|
|
})
|
|
|
|
const sortNodes = (nodes) => {
|
|
nodes.sort((a, b) => {
|
|
if (a.position !== b.position) return a.position - b.position
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
nodes.forEach((node) => sortNodes(node.children))
|
|
}
|
|
|
|
const aggregateNodes = (node) => {
|
|
if (!node.children?.length) {
|
|
return {
|
|
threads: node.threads_count ?? 0,
|
|
views: node.views_count ?? 0,
|
|
posts: node.posts_count ?? 0,
|
|
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
|
}
|
|
}
|
|
|
|
let threads = node.threads_count ?? 0
|
|
let views = node.views_count ?? 0
|
|
let posts = node.posts_count ?? 0
|
|
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
|
|
|
node.children.forEach((child) => {
|
|
const agg = aggregateNodes(child)
|
|
threads += agg.threads
|
|
views += agg.views
|
|
posts += agg.posts
|
|
if (agg.last && (!last || agg.last.at > last.at)) {
|
|
last = agg.last
|
|
}
|
|
})
|
|
|
|
node.threads_count = threads
|
|
node.views_count = views
|
|
node.posts_count = posts
|
|
if (last) {
|
|
const source = last.node
|
|
node.last_post_at = source.last_post_at
|
|
node.last_post_user_id = source.last_post_user_id
|
|
node.last_post_user_name = source.last_post_user_name
|
|
node.last_post_user_rank_color = source.last_post_user_rank_color
|
|
node.last_post_user_group_color = source.last_post_user_group_color
|
|
}
|
|
|
|
return { threads, views, posts, last }
|
|
}
|
|
|
|
sortNodes(roots)
|
|
roots.forEach((root) => aggregateNodes(root))
|
|
|
|
return roots
|
|
}, [forums])
|
|
|
|
const forumMap = useMemo(() => {
|
|
const map = new Map()
|
|
forums.forEach((forum) => {
|
|
map.set(String(forum.id), forum)
|
|
})
|
|
return map
|
|
}, [forums])
|
|
|
|
const recentThreads = useMemo(() => {
|
|
return [...threads]
|
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
|
.slice(0, 12)
|
|
}, [threads])
|
|
|
|
const roleLabel = useMemo(() => {
|
|
if (!roles?.length) return t('portal.user_role_member')
|
|
if (roles.includes('ROLE_ADMIN')) return t('portal.user_role_operator')
|
|
if (roles.includes('ROLE_MODERATOR')) return t('portal.user_role_moderator')
|
|
return t('portal.user_role_member')
|
|
}, [roles, t])
|
|
|
|
const resolveForumName = (thread) => {
|
|
if (!thread?.forum) return t('portal.unknown_forum')
|
|
const parts = thread.forum.split('/')
|
|
const id = parts[parts.length - 1]
|
|
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
|
|
}
|
|
|
|
const resolveForumId = (thread) => {
|
|
if (!thread?.forum) return null
|
|
const parts = thread.forum.split('/')
|
|
return parts[parts.length - 1] || null
|
|
}
|
|
|
|
const renderTree = (nodes, depth = 0) =>
|
|
nodes.map((node) => (
|
|
<div key={node.id}>
|
|
<div
|
|
className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
|
|
style={{ marginLeft: depth * 16 }}
|
|
>
|
|
<div className="d-flex align-items-start gap-3">
|
|
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
|
|
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
|
|
</span>
|
|
<div>
|
|
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
|
|
{node.name}
|
|
</Link>
|
|
<div className="bb-muted">{node.description || ''}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{node.children?.length > 0 && (
|
|
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
|
|
)}
|
|
</div>
|
|
))
|
|
|
|
return (
|
|
<Container fluid className="pb-4 bb-portal-shell">
|
|
<div className="bb-portal-layout">
|
|
<aside className="bb-portal-column bb-portal-column--left">
|
|
<div className="bb-portal-card">
|
|
<div className="bb-portal-card-title">{t('portal.menu')}</div>
|
|
<ul className="bb-portal-list">
|
|
<li>{t('portal.menu_news')}</li>
|
|
<li>{t('portal.menu_gallery')}</li>
|
|
<li>{t('portal.menu_calendar')}</li>
|
|
<li>{t('portal.menu_rules')}</li>
|
|
</ul>
|
|
</div>
|
|
<div className="bb-portal-card">
|
|
<div className="bb-portal-card-title">{t('portal.stats')}</div>
|
|
<div className="bb-portal-stat">
|
|
<span>{t('portal.stat_threads')}</span>
|
|
<strong>{loadingStats ? '—' : stats.threads}</strong>
|
|
</div>
|
|
<div className="bb-portal-stat">
|
|
<span>{t('portal.stat_users')}</span>
|
|
<strong>{loadingStats ? '—' : stats.users}</strong>
|
|
</div>
|
|
<div className="bb-portal-stat">
|
|
<span>{t('portal.stat_posts')}</span>
|
|
<strong>{loadingStats ? '—' : stats.posts}</strong>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="bb-portal-column bb-portal-column--center">
|
|
<div className="bb-portal-card">
|
|
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
|
|
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
|
|
{!loadingThreads && recentThreads.length === 0 && (
|
|
<p className="bb-muted">{t('portal.empty_posts')}</p>
|
|
)}
|
|
{!loadingThreads && recentThreads.length > 0 && (
|
|
<div className="bb-portal-topic-table">
|
|
<div className="bb-portal-topic-header">
|
|
<span>{t('portal.topic')}</span>
|
|
<span>{t('thread.replies')}</span>
|
|
<span>{t('thread.views')}</span>
|
|
<span>{t('thread.last_post')}</span>
|
|
</div>
|
|
{recentThreads.map((thread) => (
|
|
<PortalTopicRow
|
|
key={thread.id}
|
|
thread={thread}
|
|
forumName={resolveForumName(thread)}
|
|
forumId={resolveForumId(thread)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<aside className="bb-portal-column bb-portal-column--right">
|
|
<div className="bb-portal-card">
|
|
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
|
<div className="bb-portal-user-card">
|
|
<Link to="/ucp" className="bb-portal-user-avatar">
|
|
{profile?.avatar_url ? (
|
|
<img src={profile.avatar_url} alt="" />
|
|
) : (
|
|
<i className="bi bi-person" aria-hidden="true" />
|
|
)}
|
|
</Link>
|
|
<div className="bb-portal-user-name">
|
|
{profile?.id ? (
|
|
<Link
|
|
to={`/profile/${profile.id}`}
|
|
className="bb-portal-user-name-link"
|
|
style={
|
|
profile?.rank?.color || profile?.group_color
|
|
? { '--bb-user-link-color': profile.rank?.color || profile.group_color }
|
|
: undefined
|
|
}
|
|
>
|
|
{profile?.name || email || 'User'}
|
|
</Link>
|
|
) : (
|
|
profile?.name || email || 'User'
|
|
)}
|
|
</div>
|
|
<div className="bb-portal-user-role">{roleLabel}</div>
|
|
</div>
|
|
<ul className="bb-portal-list">
|
|
<li>{t('portal.user_new_posts')}</li>
|
|
<li>{t('portal.user_unread')}</li>
|
|
<li>{t('portal.user_control_panel')}</li>
|
|
<li>{t('portal.user_logout')}</li>
|
|
</ul>
|
|
</div>
|
|
<div className="bb-portal-card bb-portal-card--ad">
|
|
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
|
|
<div className="bb-portal-ad-box">example.com</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
{error && <p className="text-danger mt-3">{error}</p>}
|
|
</Container>
|
|
)
|
|
}
|