Files
speedBB/resources/js/pages/ForumView.jsx
tracer ef84b73cb5
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 31s
CI/CD Pipeline / promote_stable (push) Successful in 2s
Refine ACP general settings navigation and tabbed layout
2026-02-28 19:13:33 +01:00

819 lines
38 KiB
JavaScript

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,
uploadAttachment,
listAttachmentExtensionsPublic,
previewBbcode,
} 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 [threadFiles, setThreadFiles] = useState([])
const [uploading, setUploading] = useState(false)
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
const [attachmentValidationError, setAttachmentValidationError] = useState('')
const [threadDropActive, setThreadDropActive] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [previewHtml, setPreviewHtml] = useState('')
const [previewLoading, setPreviewLoading] = useState(false)
const [previewUrls, setPreviewUrls] = useState([])
const [attachmentTab, setAttachmentTab] = useState('options')
const [attachmentOptions, setAttachmentOptions] = useState({
disableBbcode: false,
disableSmilies: false,
disableAutoUrls: false,
attachSignature: true,
notifyReplies: false,
lockTopic: 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])
useEffect(() => {
listAttachmentExtensionsPublic()
.then((data) => {
if (Array.isArray(data)) {
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
}
})
.catch(() => {})
}, [])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
const created = await createThread({ title, body, forumId: id })
if (threadFiles.length > 0 && created?.id) {
setUploading(true)
for (const entry of threadFiles) {
await uploadAttachment({ threadId: created.id, file: entry.file })
}
}
setTitle('')
setBody('')
setThreadFiles([])
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setUploading(false)
setSaving(false)
}
}
const formatBytes = (bytes) => {
if (!bytes && bytes !== 0) return ''
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
const handleInlineInsert = (entry) => {
const marker = `[attachment]${entry.file.name}[/attachment]`
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
}
const clearPreviewUrls = () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url))
setPreviewUrls([])
}
const buildPreviewBody = (rawBody, entries) => {
if (!entries || entries.length === 0) {
return { body: rawBody, urls: [] }
}
const urls = []
const map = new Map()
entries.forEach((entry) => {
const file = entry.file
if (!file) return
const url = URL.createObjectURL(file)
urls.push(url)
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
})
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
const key = String(name || '').trim().toLowerCase()
if (!map.has(key)) return match
const { url, mime } = map.get(key)
if (mime.startsWith('image/')) {
return `[img]${url}[/img]`
}
return `[url=${url}]${name}[/url]`
})
return { body: replaced, urls }
}
const handlePreview = async () => {
setPreviewLoading(true)
setError('')
try {
clearPreviewUrls()
const { body: previewBody, urls } = buildPreviewBody(body || '', threadFiles)
const result = await previewBbcode(previewBody || '')
setPreviewHtml(result?.html || '')
setShowPreview(true)
setPreviewUrls(urls)
} catch (err) {
setError(err.message)
} finally {
setPreviewLoading(false)
}
}
const applyThreadFiles = (files) => {
const fileList = Array.from(files || [])
const allowed = allowedAttachmentExtensions
const rejected = []
const accepted = fileList.filter((file) => {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
rejected.push(file.name)
return false
}
return true
})
if (rejected.length > 0) {
setAttachmentValidationError(
t('attachment.invalid_extensions', { names: rejected.join(', ') })
)
} else {
setAttachmentValidationError('')
}
setThreadFiles(
accepted.map((file) => ({
id: `${file.name}-${file.lastModified}`,
file,
comment: '',
}))
)
setAttachmentTab('attachments')
}
const appendThreadFiles = (files) => {
const fileList = Array.from(files || [])
const allowed = allowedAttachmentExtensions
const rejected = []
const accepted = fileList.filter((file) => {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
rejected.push(file.name)
return false
}
return true
})
if (rejected.length > 0) {
setAttachmentValidationError(
t('attachment.invalid_extensions', { names: rejected.join(', ') })
)
} else if (accepted.length > 0) {
setAttachmentValidationError('')
}
if (accepted.length === 0) return
setThreadFiles((prev) => [
...prev,
...accepted.map((file) => ({
id: `${file.name}-${file.lastModified}`,
file,
comment: '',
})),
])
setAttachmentTab('attachments')
}
const handleThreadPaste = (event) => {
const items = Array.from(event.clipboardData?.items || [])
if (items.length === 0) return
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
if (imageItems.length === 0) return
event.preventDefault()
const files = imageItems
.map((item) => item.getAsFile())
.filter(Boolean)
.map((file) => {
const ext = file.type?.split('/')[1] || 'png'
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
return new File([file], name, { type: file.type })
})
appendThreadFiles(files)
if (files.length > 0) {
const marker = `[attachment]${files[0].name}[/attachment]`
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
}
}
const renderAttachmentFooter = () => (
<div className="bb-attachment-panel">
<div className="bb-attachment-tabs">
<button
type="button"
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('options')}
>
<i className="bi bi-sliders me-2" aria-hidden="true" />
{t('attachment.tab_options')}
</button>
<button
type="button"
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('attachments')}
>
<i className="bi bi-paperclip me-2" aria-hidden="true" />
{t('attachment.tab_attachments')}
</button>
</div>
<div className="bb-attachment-body">
{attachmentTab === 'options' && (
<div className="bb-attachment-options">
<Form.Check
type="checkbox"
id="bb-option-disable-bbcode"
label={t('attachment.option_disable_bbcode')}
checked={attachmentOptions.disableBbcode}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
disableBbcode: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-disable-smilies"
label={t('attachment.option_disable_smilies')}
checked={attachmentOptions.disableSmilies}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
disableSmilies: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-disable-auto-urls"
label={t('attachment.option_disable_auto_urls')}
checked={attachmentOptions.disableAutoUrls}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
disableAutoUrls: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-attach-signature"
label={t('attachment.option_attach_signature')}
checked={attachmentOptions.attachSignature}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
attachSignature: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-notify-replies"
label={t('attachment.option_notify_replies')}
checked={attachmentOptions.notifyReplies}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
notifyReplies: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-lock-topic"
label={t('attachment.option_lock_topic')}
checked={attachmentOptions.lockTopic}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
lockTopic: event.target.checked,
}))
}
/>
</div>
)}
{attachmentTab === 'attachments' && (
<>
<p className="bb-muted mb-2">
{t('attachment.hint')}
</p>
<p className="bb-muted mb-3">
{t('attachment.max_size', { size: '25 MB' })}
</p>
<div className="bb-attachment-actions">
<Button
type="button"
size="sm"
variant="outline-secondary"
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
>
<i className="bi bi-upload me-2" aria-hidden="true" />
{t('attachment.add_files')}
</Button>
</div>
{attachmentValidationError && (
<p className="text-danger mb-2">{attachmentValidationError}</p>
)}
<table className="table bb-attachment-table">
<thead className="tr-header">
<tr>
<th scope="col" className="text-start">{t('attachment.filename')}</th>
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
<th scope="col" className="text-start">{t('attachment.size')}</th>
<th scope="col" className="text-start">{t('attachment.status')}</th>
<th scope="col" className="text-start">{t('attachment.actions')}</th>
</tr>
</thead>
<tbody>
{threadFiles.length === 0 && (
<tr>
<td colSpan={5} className="bb-attachment-empty">
{t('attachment.empty')}
</td>
</tr>
)}
{threadFiles.map((entry) => (
<tr key={entry.id} className="bb-attachment-row">
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
{entry.file.name}
</td>
<td className="bb-attachment-cell-comment">
<Form.Control
className="bb-attachment-comment"
value={entry.comment}
onChange={(event) =>
setThreadFiles((prev) =>
prev.map((item) =>
item.id === entry.id
? { ...item, comment: event.target.value }
: item
)
)
}
placeholder={t('attachment.file_comment_placeholder')}
/>
</td>
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
{formatBytes(entry.file.size)}
</td>
<td className="bb-attachment-status text-center">
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
</td>
<td>
<div className="bb-attachment-row-actions">
<button
type="button"
className="bb-attachment-action"
onClick={() => handleInlineInsert(entry)}
title={t('attachment.place_inline')}
aria-label={t('attachment.place_inline')}
>
<i className="bi bi-paperclip" aria-hidden="true" />
</button>
<button
type="button"
className="bb-attachment-action"
onClick={() =>
setThreadFiles((prev) =>
prev.filter((item) => item.id !== entry.id)
)
}
title={t('attachment.delete_file')}
aria-label={t('attachment.delete_file')}
>
<i className="bi bi-trash" aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
)
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>
<i className="bi bi-chevron-left" aria-hidden="true" />
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
<i className="bi bi-dot me-1" aria-hidden="true" />
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
<i className="bi bi-chevron-right" aria-hidden="true" />
</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 tr-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"
dialogClassName="bb-thread-modal"
>
<Modal.Header closeButton>
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
</Modal.Header>
<Modal.Body className="d-flex flex-column p-0">
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Form onSubmit={handleSubmit} className="d-flex flex-column flex-grow-1 px-3 pb-3 pt-2">
<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 d-flex flex-column flex-grow-1">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
className="flex-grow-1"
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleThreadPaste}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Control
id="bb-thread-attachment-input"
type="file"
multiple
className="bb-attachment-input"
disabled={!token || saving || uploading}
onChange={(event) => {
applyThreadFiles(event.target.files)
event.target.value = ''
}}
/>
<div
className={`bb-attachment-drop ${threadDropActive ? 'is-dragover' : ''}`}
role="button"
tabIndex={0}
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
document.getElementById('bb-thread-attachment-input')?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setThreadDropActive(true)
}}
onDragLeave={() => setThreadDropActive(false)}
onDrop={(event) => {
event.preventDefault()
setThreadDropActive(false)
applyThreadFiles(event.dataTransfer.files)
}}
>
<span>
{t('attachment.drop_hint')}{' '}
<button
type="button"
className="bb-attachment-drop-link"
onClick={(event) => {
event.stopPropagation()
document.getElementById('bb-thread-attachment-input')?.click()
}}
>
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
{t('attachment.drop_browse')}
</button>
</span>
</div>
{renderAttachmentFooter()}
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')}
</Button>
<div className="d-flex gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={handlePreview}
disabled={!token || saving || uploading || previewLoading}
>
<i className="bi bi-eye me-2" aria-hidden="true" />
{t('form.preview')}
</Button>
<Button
type="submit"
className="bb-accent-button"
disabled={!token || saving || uploading}
>
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
{saving || uploading ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
</Modal.Footer>
</Form>
</Modal.Body>
</Modal>
)}
<Modal
show={showPreview}
onHide={() => {
setShowPreview(false)
clearPreviewUrls()
}}
centered
size="lg"
>
<Modal.Header closeButton>
<Modal.Title>{t('form.preview')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div
className="bb-post-body"
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
/>
</Modal.Body>
</Modal>
</Container>
)
}