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) => (
{node.name}
{node.description || ''}
{node.threads_count ?? 0}
{node.views_count ?? 0}
{node.last_post_at ? (
{t('thread.by')}{' '} {node.last_post_user_id ? ( {node.last_post_user_name || t('thread.anonymous')} ) : ( {node.last_post_user_name || t('thread.anonymous')} )} {node.last_post_at.slice(0, 10)}
) : ( {t('thread.no_replies')} )}
)) 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 = () => (
{attachmentTab === 'options' && (
setAttachmentOptions((prev) => ({ ...prev, disableBbcode: event.target.checked, })) } /> setAttachmentOptions((prev) => ({ ...prev, disableSmilies: event.target.checked, })) } /> setAttachmentOptions((prev) => ({ ...prev, disableAutoUrls: event.target.checked, })) } /> setAttachmentOptions((prev) => ({ ...prev, attachSignature: event.target.checked, })) } /> setAttachmentOptions((prev) => ({ ...prev, notifyReplies: event.target.checked, })) } /> setAttachmentOptions((prev) => ({ ...prev, lockTopic: event.target.checked, })) } />
)} {attachmentTab === 'attachments' && ( <>

{t('attachment.hint')}

{t('attachment.max_size', { size: '25 MB' })}

{attachmentValidationError && (

{attachmentValidationError}

)} {threadFiles.length === 0 && ( )} {threadFiles.map((entry) => ( ))}
{t('attachment.filename')} {t('attachment.file_comment')} {t('attachment.size')} {t('attachment.status')} {t('attachment.actions')}
{t('attachment.empty')}
{entry.file.name} setThreadFiles((prev) => prev.map((item) => item.id === entry.id ? { ...item, comment: event.target.value } : item ) ) } placeholder={t('attachment.file_comment_placeholder')} /> {formatBytes(entry.file.size)}
)}
) return ( {loading &&

{t('forum.loading')}

} {error &&

{error}

} {forum && ( <> {forum.type !== 'forum' && (
{forum.name}
{t('portal.topic')} {t('thread.views')} {t('thread.last_post')}
{children.length > 0 ? ( renderChildRows(children) ) : (
{t('forum.empty_children')}
)}
)} {forum.type === 'forum' && ( <>