Files
speedBB/resources/js/pages/ForumView.jsx
2026-01-23 19:26:41 +01:00

332 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { createThread, getForum, listAllForums, listThreadsByForum } from '../api/client'
import PortalTopicRow from '../components/PortalTopicRow'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function ForumView() {
const { id } = useParams()
const { token } = useAuth()
const [forum, setForum] = useState(null)
const [children, setChildren] = useState([])
const [threads, setThreads] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const { t } = useTranslation()
const renderChildRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link
to={`/profile/${node.last_post_user_id}`}
className="bb-board-last-link"
style={
node.last_post_user_rank_color || node.last_post_user_group_color
? {
'--bb-user-link-color':
node.last_post_user_rank_color || node.last_post_user_group_color,
}
: undefined
}
>
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div>
</div>
))
const getParentId = (node) => {
if (!node.parent) return null
if (typeof node.parent === 'string') {
return node.parent.split('/').pop()
}
return node.parent.id ?? null
}
const buildForumTree = (allForums) => {
const map = new Map()
const roots = []
allForums.forEach((item) => {
map.set(String(item.id), { ...item, children: [] })
})
allForums.forEach((item) => {
const parentId = getParentId(item)
const node = map.get(String(item.id))
if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
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 }
}
roots.forEach((root) => aggregateNodes(root))
return map
}
useEffect(() => {
let active = true
const loadData = async () => {
setLoading(true)
setError('')
try {
const forumData = await getForum(id)
if (!active) return
setForum(forumData)
const allForums = await listAllForums()
if (!active) return
const treeMap = buildForumTree(allForums)
const currentNode = treeMap.get(String(forumData.id))
setChildren(currentNode?.children ?? [])
if (forumData.type === 'forum') {
const threadData = await listThreadsByForum(id)
if (!active) return
setThreads(threadData)
} else {
setThreads([])
}
} catch (err) {
if (active) setError(err.message)
} finally {
if (active) setLoading(false)
}
}
loadData()
return () => {
active = false
}
}, [id])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createThread({ title, body, forumId: id })
setTitle('')
setBody('')
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<Row className="g-4">
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
</section>
</div>
)}
{forum.type === 'forum' && (
<>
<div className="bb-topic-toolbar mt-4 mb-2">
<div className="bb-topic-toolbar__left">
<Button
variant="dark"
className="bb-topic-action bb-accent-button"
onClick={() => setShowModal(true)}
disabled={!token || saving}
>
<i className="bi bi-pencil me-2" aria-hidden="true" />
{t('forum.start_thread')}
</Button>
</div>
<div className="bb-topic-toolbar__right">
<span className="bb-topic-count">
{threads.length} {t('forum.threads').toLowerCase()}
</span>
<div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled>
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
</Button>
</div>
</div>
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<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>
{threads.length === 0 && (
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
)}
{threads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={forum?.name || t('portal.unknown_forum')}
forumId={forum?.id}
showForum={false}
/>
))}
</div>
</>
)}
</Col>
</Row>
</>
)}
{forum?.type === 'forum' && (
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
placeholder={t('form.thread_title_placeholder')}
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
)}
</Container>
)
}