import { useEffect, useMemo, useRef, useState } from 'react' import { Button, Container, Form, Modal } from 'react-bootstrap' import { useParams } from 'react-router-dom' import { createPost, deleteThread, getThread, listPostsByThread, updateThreadSolved, updateThread, updatePost, deletePost, uploadAttachment, listAttachmentExtensionsPublic, previewBbcode, } from '../api/client' import { useAuth } from '../context/AuthContext' import { useTranslation } from 'react-i18next' export default function ThreadView() { const { id } = useParams() const { token, userId, isAdmin } = useAuth() const [thread, setThread] = useState(null) const [posts, setPosts] = useState([]) const [error, setError] = useState('') const [loading, setLoading] = useState(true) const [body, setBody] = useState('') const [saving, setSaving] = useState(false) const [solving, setSolving] = useState(false) const [threadFiles, setThreadFiles] = useState([]) const [threadUploading, setThreadUploading] = useState(false) const [replyFiles, setReplyFiles] = useState([]) const [replyUploading, setReplyUploading] = useState(false) const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([]) const [attachmentValidationError, setAttachmentValidationError] = useState('') const [replyDropActive, setReplyDropActive] = useState(false) const [showPreview, setShowPreview] = useState(false) const [previewHtml, setPreviewHtml] = useState('') const [previewLoading, setPreviewLoading] = useState(false) const [previewUrls, setPreviewUrls] = useState([]) const [lightboxIndex, setLightboxIndex] = useState(null) const [lightboxOverride, setLightboxOverride] = useState(null) const [editPost, setEditPost] = useState(null) const [editBody, setEditBody] = useState('') const [editTitle, setEditTitle] = useState('') const [editSaving, setEditSaving] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [deleteLoading, setDeleteLoading] = useState(false) const [deleteReason, setDeleteReason] = useState('obsolete') const [deleteReasonText, setDeleteReasonText] = useState('') const [replyAttachmentTab, setReplyAttachmentTab] = useState('options') const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({ disableBbcode: false, disableSmilies: false, disableAutoUrls: false, attachSignature: true, notifyReplies: false, }) const { t } = useTranslation() const replyRef = useRef(null) useEffect(() => { setLoading(true) Promise.all([getThread(id), listPostsByThread(id)]) .then(([threadData, postData]) => { setThread(threadData) setPosts(postData) }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) }, [id]) useEffect(() => { listAttachmentExtensionsPublic() .then((data) => { if (Array.isArray(data)) { setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase())) } }) .catch(() => {}) }, []) useEffect(() => { if (!thread && posts.length === 0) return const hash = window.location.hash if (!hash) return const targetId = hash.replace('#', '') if (!targetId) return const target = document.getElementById(targetId) if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }) } }, [thread, posts]) const handleSubmit = async (event) => { event.preventDefault() setSaving(true) setError('') try { const created = await createPost({ body, threadId: id }) if (replyFiles.length > 0 && created?.id) { setReplyUploading(true) for (const entry of replyFiles) { await uploadAttachment({ postId: created.id, file: entry.file }) } } setBody('') setReplyFiles([]) const updated = await listPostsByThread(id) setPosts(updated) } catch (err) { setError(err.message) } finally { setReplyUploading(false) setSaving(false) } } // const replyCount = posts.length const formatDate = (value) => { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return '—' const day = String(date.getDate()).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0') const year = String(date.getFullYear()) return `${day}.${month}.${year}` } 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 || '', replyFiles) const result = await previewBbcode(previewBody || '') setPreviewHtml(result?.html || '') setShowPreview(true) setPreviewUrls(urls) } catch (err) { setError(err.message) } finally { setPreviewLoading(false) } } const applyReplyFiles = (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('') } setReplyFiles( accepted.map((file) => ({ id: `${file.name}-${file.lastModified}`, file, comment: '', })) ) setReplyAttachmentTab('attachments') } const appendReplyFiles = (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 setReplyFiles((prev) => [ ...prev, ...accepted.map((file) => ({ id: `${file.name}-${file.lastModified}`, file, comment: '', })), ]) setReplyAttachmentTab('attachments') } const handleReplyPaste = (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 }) }) appendReplyFiles(files) if (files.length > 0) { const marker = `[attachment]${files[0].name}[/attachment]` setBody((prev) => (prev ? `${prev}\n${marker}` : marker)) } } const renderAttachmentFooter = () => (
{replyAttachmentTab === 'options' && (
setReplyAttachmentOptions((prev) => ({ ...prev, disableBbcode: event.target.checked, })) } /> setReplyAttachmentOptions((prev) => ({ ...prev, disableSmilies: event.target.checked, })) } /> setReplyAttachmentOptions((prev) => ({ ...prev, disableAutoUrls: event.target.checked, })) } /> setReplyAttachmentOptions((prev) => ({ ...prev, attachSignature: event.target.checked, })) } /> setReplyAttachmentOptions((prev) => ({ ...prev, notifyReplies: event.target.checked, })) } />
)} {replyAttachmentTab === 'attachments' && ( <>

{t('attachment.hint')}

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

{attachmentValidationError && (

{attachmentValidationError}

)} {replyFiles.length === 0 && ( )} {replyFiles.map((entry) => ( ))}
{t('attachment.filename')} {t('attachment.file_comment')} {t('attachment.size')} {t('attachment.status')} {t('attachment.actions')}
{t('attachment.empty')}
{entry.file.name} setReplyFiles((prev) => prev.map((item) => item.id === entry.id ? { ...item, comment: event.target.value } : item ) ) } placeholder={t('attachment.file_comment_placeholder')} /> {formatBytes(entry.file.size)}
)}
) const renderAttachments = (attachments) => { if (!attachments || attachments.length === 0) return null return (
{attachments.map((attachment) => ( attachment.is_image ? ( ) : (