819 lines
38 KiB
JavaScript
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>
|
|
)
|
|
}
|