811 lines
37 KiB
JavaScript
811 lines
37 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')}
|
||
>
|
||
{t('attachment.tab_options')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||
onClick={() => setAttachmentTab('attachments')}
|
||
>
|
||
{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()}
|
||
>
|
||
{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>
|
||
‹
|
||
</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 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()
|
||
}}
|
||
>
|
||
{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)}>
|
||
{t('acp.cancel')}
|
||
</Button>
|
||
<div className="d-flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline-secondary"
|
||
onClick={handlePreview}
|
||
disabled={!token || saving || uploading || previewLoading}
|
||
>
|
||
{t('form.preview')}
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
className="bb-accent-button"
|
||
disabled={!token || saving || uploading}
|
||
>
|
||
{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>
|
||
)
|
||
}
|