UI: portal/header refinements, board index UX, and user settings
This commit is contained in:
@@ -1,20 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Badge, Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listAllForums } from '../api/client'
|
||||
import { listAllForums, listThreads } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Home() {
|
||||
const [forums, setForums] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingForums, setLoadingForums] = useState(true)
|
||||
const [loadingThreads, setLoadingThreads] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
.finally(() => setLoadingForums(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingThreads(false))
|
||||
}, [])
|
||||
|
||||
const getParentId = (forum) => {
|
||||
@@ -56,6 +65,33 @@ export default function Home() {
|
||||
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 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}>
|
||||
@@ -82,22 +118,111 @@ export default function Home() {
|
||||
))
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">{t('app.brand')}</p>
|
||||
<h1 className="mt-3">{t('home.hero_title')}</h1>
|
||||
<p className="bb-muted mb-0">
|
||||
{t('home.hero_body')}
|
||||
</p>
|
||||
</div>
|
||||
<Container 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>{threads.length}</strong>
|
||||
</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_forums')}</span>
|
||||
<strong>{forums.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
|
||||
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{!loading && forumTree.length === 0 && (
|
||||
<p className="bb-muted">{t('home.empty')}</p>
|
||||
)}
|
||||
{forumTree.length > 0 && <div className="mt-2">{renderTree(forumTree)}</div>}
|
||||
<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) => (
|
||||
<div className="bb-portal-topic-row" key={thread.id}>
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
<Badge bg="secondary">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</Badge>
|
||||
<span className="bb-portal-topic-forum">
|
||||
{t('portal.forum_label')}{' '}
|
||||
{resolveForumId(thread) ? (
|
||||
<Link
|
||||
to={`/forum/${resolveForumId(thread)}`}
|
||||
className="bb-portal-topic-forum-link"
|
||||
>
|
||||
{resolveForumName(thread)}
|
||||
</Link>
|
||||
) : (
|
||||
resolveForumName(thread)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">0</div>
|
||||
<div className="bb-portal-topic-cell">—</div>
|
||||
<div className="bb-portal-topic-cell">
|
||||
{thread.created_at?.slice(0, 10) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<div className="bb-portal-user-avatar" />
|
||||
<div className="bb-portal-user-name">tracer</div>
|
||||
<div className="bb-portal-user-role">Operator</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user