Files
speedBB/resources/js/pages/ThreadView.jsx
tracer 9c60a8944e
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
feat: system tools and admin enhancements
2026-01-31 20:12:09 +01:00

1266 lines
60 KiB
JavaScript

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 = () => (
<div className="bb-attachment-panel">
<div className="bb-attachment-tabs">
<button
type="button"
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('options')}
>
{t('attachment.tab_options')}
</button>
<button
type="button"
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('attachments')}
>
{t('attachment.tab_attachments')}
</button>
</div>
<div className="bb-attachment-body">
{replyAttachmentTab === 'options' && (
<div className="bb-attachment-options">
<Form.Check
type="checkbox"
id="bb-reply-option-disable-bbcode"
label={t('attachment.option_disable_bbcode')}
checked={replyAttachmentOptions.disableBbcode}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
disableBbcode: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-disable-smilies"
label={t('attachment.option_disable_smilies')}
checked={replyAttachmentOptions.disableSmilies}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
disableSmilies: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-disable-auto-urls"
label={t('attachment.option_disable_auto_urls')}
checked={replyAttachmentOptions.disableAutoUrls}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
disableAutoUrls: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-attach-signature"
label={t('attachment.option_attach_signature')}
checked={replyAttachmentOptions.attachSignature}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
attachSignature: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-notify-replies"
label={t('attachment.option_notify_replies')}
checked={replyAttachmentOptions.notifyReplies}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
notifyReplies: event.target.checked,
}))
}
/>
</div>
)}
{replyAttachmentTab === '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-reply-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>
{replyFiles.length === 0 && (
<tr>
<td colSpan={5} className="bb-attachment-empty">
{t('attachment.empty')}
</td>
</tr>
)}
{replyFiles.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) =>
setReplyFiles((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={() =>
setReplyFiles((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>
)
const renderAttachments = (attachments) => {
if (!attachments || attachments.length === 0) return null
return (
<div className="bb-attachment-list">
{attachments.map((attachment) => (
attachment.is_image ? (
<button
key={attachment.id}
type="button"
className="bb-attachment-item border-0 text-start"
onClick={() => {
const idKey = attachment.id !== undefined && attachment.id !== null
? String(attachment.id)
: null
let index = idKey ? lightboxIndexById.get(idKey) : undefined
if (index === undefined) {
const target = attachment.download_url
index = lightboxImages.findIndex((entry) =>
matchesLightboxEntry(target, entry)
)
}
if (index !== undefined && index >= 0) {
setLightboxOverride(null)
setLightboxIndex(index)
return
}
setLightboxOverride([
{
id: attachment.id,
url: attachment.download_url,
thumb: attachment.thumbnail_url,
name: attachment.original_name,
},
])
setLightboxIndex(0)
}}
>
<img
src={attachment.thumbnail_url || attachment.download_url}
alt={attachment.original_name}
className="img-fluid rounded"
style={{ width: 72, height: 72, objectFit: 'cover' }}
/>
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</button>
) : (
<a
key={attachment.id}
href={attachment.download_url}
className="bb-attachment-item"
download
>
<i className="bi bi-paperclip" aria-hidden="true" />
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</a>
)
))}
</div>
)
}
const allPosts = useMemo(() => {
if (!thread) return posts
const rootPost = {
id: `thread-${thread.id}`,
body: thread.body,
body_html: thread.body_html,
created_at: thread.created_at,
user_id: thread.user_id,
user_name: thread.user_name,
user_avatar_url: thread.user_avatar_url,
user_posts_count: thread.user_posts_count,
user_created_at: thread.user_created_at,
user_location: thread.user_location,
user_thanks_given_count: thread.user_thanks_given_count,
user_thanks_received_count: thread.user_thanks_received_count,
user_rank_name: thread.user_rank_name,
user_rank_badge_type: thread.user_rank_badge_type,
user_rank_badge_text: thread.user_rank_badge_text,
user_rank_badge_url: thread.user_rank_badge_url,
attachments: thread.attachments || [],
isRoot: true,
}
return [rootPost, ...posts]
}, [posts, thread])
const lightboxImages = useMemo(() => {
return allPosts
.flatMap((post) => post.attachments || [])
.filter((attachment) => attachment?.is_image)
.map((attachment) => ({
id: attachment.id,
url: attachment.download_url,
thumb: attachment.thumbnail_url,
name: attachment.original_name,
}))
}, [allPosts])
const lightboxItems = lightboxOverride || lightboxImages
const matchesLightboxEntry = (src, entry) => {
if (!src || !entry) return false
return src.endsWith(entry.url) || (entry.thumb && src.endsWith(entry.thumb))
}
const canEditPost = (post) => {
if (!token || !post) return false
return isAdmin || Number(post.user_id) === Number(userId)
}
const replaceAttachmentTags = (body, attachments) => {
if (!body || !attachments || attachments.length === 0) return body
const map = new Map()
attachments.forEach((attachment) => {
if (!attachment?.original_name) return
map.set(String(attachment.original_name).toLowerCase(), attachment)
})
if (map.size === 0) return body
return body.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
const key = String(name).trim().toLowerCase()
const attachment = map.get(key)
if (!attachment) return match
const url = attachment.download_url
if (attachment.is_image) {
if (attachment.thumbnail_url) {
return `[url=${url}][img]${attachment.thumbnail_url}[/img][/url]`
}
return `[img]${url}[/img]`
}
return `[url=${url}]${attachment.original_name}[/url]`
})
}
const buildQuoteBody = (post) => {
if (!post) return ''
const author = post.user_name || t('thread.anonymous')
const content = replaceAttachmentTags(post.body || '', post.attachments || [])
return `[quote=${author}]${content}[/quote]\n`
}
const handleQuote = (post) => {
const snippet = buildQuoteBody(post)
setBody((prev) => (prev ? `${prev}\n${snippet}` : snippet))
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const handleEditStart = (post) => {
if (!post) return
if (post.isRoot) {
setEditTitle(thread?.title || '')
} else {
setEditTitle('')
}
setEditBody(post.body || '')
setEditPost(post)
}
const handleEditSave = async () => {
if (!editPost || editSaving) return
setEditSaving(true)
setError('')
try {
if (editPost.isRoot) {
const payload = {
title: editTitle.trim(),
body: editBody,
}
const updated = await updateThread(thread.id, payload)
setThread(updated)
} else {
const updated = await updatePost(editPost.id, { body: editBody })
setPosts((prev) => prev.map((post) => (post.id === updated.id ? updated : post)))
}
setEditPost(null)
} catch (err) {
setError(err.message)
} finally {
setEditSaving(false)
}
}
const handleDeletePost = (post) => {
if (!post) return
setDeleteReason('obsolete')
setDeleteReasonText('')
setDeleteTarget({
post,
isThread: Boolean(post.isRoot),
})
}
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleteLoading) return
setDeleteLoading(true)
setError('')
try {
const payload = {
reason: deleteReason,
reason_text: deleteReason === 'other' ? deleteReasonText.trim() : '',
}
if (deleteTarget.isThread) {
await deleteThread(thread.id, payload)
window.location.href = '/forums'
} else {
await deletePost(deleteTarget.post.id, payload)
setPosts((prev) => prev.filter((item) => item.id !== deleteTarget.post.id))
}
setDeleteTarget(null)
} catch (err) {
setError(err.message)
} finally {
setDeleteLoading(false)
}
}
const lightboxIndexById = useMemo(() => {
const map = new Map()
lightboxImages.forEach((entry, index) => {
if (entry.id !== undefined && entry.id !== null) {
map.set(String(entry.id), index)
}
})
return map
}, [lightboxImages])
const handleJumpToReply = () => {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const canToggleSolved = token
&& thread
&& (Number(thread.user_id) === Number(userId) || isAdmin)
const canUploadThread = token
&& thread
&& (Number(thread.user_id) === Number(userId) || isAdmin)
const handleToggleSolved = async () => {
if (!thread || solving) return
setSolving(true)
setError('')
try {
const updated = await updateThreadSolved(thread.id, !thread.solved)
setThread(updated)
} catch (err) {
setError(err.message)
} finally {
setSolving(false)
}
}
const handleThreadUpload = async () => {
if (!thread || threadFiles.length === 0 || threadUploading) return
setThreadUploading(true)
setError('')
try {
for (const file of threadFiles) {
await uploadAttachment({ threadId: thread.id, file })
}
setThreadFiles([])
const [threadData, postData] = await Promise.all([getThread(id), listPostsByThread(id)])
setThread(threadData)
setPosts(postData)
} catch (err) {
setError(err.message)
} finally {
setThreadUploading(false)
}
}
const totalPosts = allPosts.length
return (
<Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{thread && (
<div className="bb-thread">
<div className="bb-thread-titlebar">
<h1 className="bb-thread-title">
{thread.title}
{thread.solved && (
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
)}
</h1>
<div className="bb-thread-meta">
<span>{t('thread.by')}</span>
<span className="bb-thread-author">
{thread.user_name || t('thread.anonymous')}
</span>
{thread.created_at && (
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
)}
</div>
</div>
<div className="bb-thread-toolbar">
<div className="bb-thread-actions">
<Button className="bb-accent-button" onClick={handleJumpToReply}>
<i className="bi bi-reply-fill" aria-hidden="true" />
<span>{t('form.post_reply')}</span>
</Button>
{canToggleSolved && (
<Button
variant="outline-secondary"
className="bb-thread-solved-toggle"
onClick={handleToggleSolved}
disabled={solving}
>
<i
className={`bi ${thread.solved ? 'bi-check-circle-fill' : 'bi-check-circle'}`}
aria-hidden="true"
/>
<span>{thread.solved ? t('thread.mark_unsolved') : t('thread.mark_solved')}</span>
</Button>
)}
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
<i className="bi bi-wrench" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
<i className="bi bi-gear" aria-hidden="true" />
</button>
</div>
<div className="bb-thread-meta-right">
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
<span></span>
<span>Page 1 of 1</span>
</div>
</div>
<div className="bb-posts">
{allPosts.map((post, index) => {
const authorName = post.author?.username
|| post.user_name
|| post.author_name
|| t('thread.anonymous')
const currentUserId = Number(userId)
const postUserId = Number(post.user_id)
const canThank = Number.isFinite(currentUserId)
&& Number.isFinite(postUserId)
&& currentUserId !== postUserId
console.log('canThank check', {
postId: post.id,
postUserId,
currentUserId,
canThank,
})
const topicLabel = thread?.title
? post.isRoot
? thread.title
: `${t('thread.reply_prefix')} ${thread.title}`
: ''
const postNumber = index + 1
return (
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">
{post.user_rank_name || ''}
</div>
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
<div className="bb-post-author-badge">
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
<img src={post.user_rank_badge_url} alt="" />
) : (
<span>{post.user_rank_badge_text}</span>
)}
</div>
)}
<div className="bb-post-author-meta">
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.posts')}:</span>
<span className="bb-post-author-value">
{post.user_posts_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.registered')}:</span>
<span className="bb-post-author-value">
{formatDate(post.user_created_at)}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.location')}:</span>
<span className="bb-post-author-value">
{post.user_location || '-'}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_given_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_received_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
</div>
</aside>
<div className="bb-post-content">
<div className="bb-post-header">
<div className="bb-post-header-meta">
{topicLabel && (
<span className="bb-post-topic">
#{postNumber} {topicLabel}
</span>
)}
<span>{t('thread.by')} {authorName}</span>
{post.created_at && (
<span>{post.created_at.slice(0, 10)}</span>
)}
</div>
<div className="bb-post-actions">
<button
type="button"
className="bb-post-action"
aria-label={t('thread.edit')}
title={t('thread.edit')}
onClick={() => handleEditStart(post)}
disabled={!canEditPost(post)}
>
<i className="bi bi-pencil" aria-hidden="true" />
</button>
<button
type="button"
className="bb-post-action"
aria-label={t('thread.delete')}
title={t('thread.delete')}
onClick={() => handleDeletePost(post)}
disabled={!canEditPost(post)}
>
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Report post">
<i className="bi bi-exclamation-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Post info">
<i className="bi bi-info-lg" aria-hidden="true" />
</button>
<button
type="button"
className="bb-post-action"
aria-label={t('thread.quote')}
title={t('thread.quote')}
onClick={() => handleQuote(post)}
>
<i className="bi bi-quote" aria-hidden="true" />
</button>
{canThank && (
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
</button>
)}
</div>
</div>
<div
className="bb-post-body"
onClick={(event) => {
if (event.target?.tagName === 'IMG') {
event.preventDefault()
const src = event.target.src
const index = lightboxImages.findIndex((entry) =>
matchesLightboxEntry(src, entry)
)
if (index >= 0) {
setLightboxOverride(null)
setLightboxIndex(index)
return
}
setLightboxOverride([{ id: null, url: src, thumb: src, name: '' }])
setLightboxIndex(0)
}
}}
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
/>
{renderAttachments(post.attachments)}
<div className="bb-post-footer">
<div className="bb-post-actions">
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
<i className="bi bi-chevron-up" aria-hidden="true" />
</a>
</div>
</div>
</div>
</article>
)
})}
</div>
<div className="bb-thread-reply" ref={replyRef}>
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
{!token && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.message')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleReplyPaste}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Control
id="bb-reply-attachment-input"
type="file"
multiple
className="bb-attachment-input"
disabled={!token || saving || replyUploading}
onChange={(event) => {
applyReplyFiles(event.target.files)
event.target.value = ''
}}
/>
<div
className={`bb-attachment-drop ${replyDropActive ? 'is-dragover' : ''}`}
role="button"
tabIndex={0}
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
document.getElementById('bb-reply-attachment-input')?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setReplyDropActive(true)
}}
onDragLeave={() => setReplyDropActive(false)}
onDrop={(event) => {
event.preventDefault()
setReplyDropActive(false)
applyReplyFiles(event.dataTransfer.files)
}}
>
<span>
{t('attachment.drop_hint')}{' '}
<button
type="button"
className="bb-attachment-drop-link"
onClick={(event) => {
event.stopPropagation()
document.getElementById('bb-reply-attachment-input')?.click()
}}
>
{t('attachment.drop_browse')}
</button>
</span>
</div>
{renderAttachmentFooter()}
<div className="bb-thread-reply-actions">
<div className="d-flex gap-2 justify-content-end">
<Button
type="button"
variant="outline-secondary"
onClick={handlePreview}
disabled={!token || saving || replyUploading || previewLoading}
>
{t('form.preview')}
</Button>
<Button
type="submit"
className="bb-accent-button"
disabled={!token || saving || replyUploading}
>
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
</Button>
</div>
</div>
</Form>
</div>
</div>
)}
<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>
<Modal
show={Boolean(editPost)}
onHide={() => setEditPost(null)}
centered
size="lg"
>
<Modal.Header closeButton>
<Modal.Title>{t('thread.edit')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{editPost?.isRoot && (
<Form.Group className="mb-3">
<Form.Label>{t('thread.title')}</Form.Label>
<Form.Control
type="text"
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
/>
</Form.Group>
)}
<Form.Group>
<Form.Label>{t('form.message')}</Form.Label>
<Form.Control
as="textarea"
rows={8}
value={editBody}
onChange={(event) => setEditBody(event.target.value)}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer className="justify-content-between">
<Button variant="outline-secondary" onClick={() => setEditPost(null)}>
{t('acp.cancel')}
</Button>
<Button
className="bb-accent-button"
onClick={handleEditSave}
disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())}
>
{editSaving ? t('form.saving') : t('acp.save')}
</Button>
</Modal.Footer>
</Modal>
<Modal
show={Boolean(deleteTarget)}
onHide={() => {
if (deleteLoading) return
setDeleteTarget(null)
}}
centered
>
<Modal.Header closeButton>
<Modal.Title>
{deleteTarget?.isThread ? t('thread.delete_confirm') : t('thread.delete_post_confirm')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="mb-3">
{deleteTarget?.isThread
? t('thread.delete_confirm')
: t('thread.delete_post_confirm')}
</p>
<Form.Group className="mb-3">
<Form.Label>{t('thread.delete_reason')}</Form.Label>
<Form.Select
value={deleteReason}
onChange={(event) => setDeleteReason(event.target.value)}
disabled={deleteLoading}
>
<option value="obsolete">{t('thread.delete_reason_obsolete')}</option>
<option value="double">{t('thread.delete_reason_double')}</option>
<option value="other">{t('thread.delete_reason_other')}</option>
</Form.Select>
</Form.Group>
{deleteReason === 'other' && (
<Form.Group>
<Form.Label>{t('thread.delete_reason_other_label')}</Form.Label>
<Form.Control
type="text"
value={deleteReasonText}
onChange={(event) => setDeleteReasonText(event.target.value)}
placeholder={t('thread.delete_reason_other_placeholder')}
disabled={deleteLoading}
/>
</Form.Group>
)}
</Modal.Body>
<Modal.Footer className="justify-content-between">
<Button
variant="outline-secondary"
onClick={() => setDeleteTarget(null)}
disabled={deleteLoading}
>
{t('acp.cancel')}
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
disabled={deleteLoading}
>
{deleteLoading ? t('form.saving') : t('acp.delete')}
</Button>
</Modal.Footer>
</Modal>
<Modal
show={lightboxIndex !== null && lightboxItems.length > 0}
onHide={() => {
setLightboxIndex(null)
setLightboxOverride(null)
}}
centered
size="lg"
dialogClassName="bb-lightbox-modal"
>
<Modal.Header closeButton>
<Modal.Title>
{lightboxIndex !== null
? `${(lightboxIndex ?? 0) + 1} / ${lightboxItems.length}`
: ''}
</Modal.Title>
</Modal.Header>
<Modal.Body className="text-center bb-lightbox-body">
{lightboxIndex !== null && lightboxItems[lightboxIndex] && (
<img
src={lightboxItems[lightboxIndex].url}
alt={lightboxItems[lightboxIndex].name || ''}
className="img-fluid rounded"
/>
)}
<div className="bb-lightbox-controls">
<Button
type="button"
variant="dark"
className="bb-lightbox-btn"
onClick={() =>
setLightboxIndex((prev) => (prev === null ? prev : Math.max(0, prev - 1)))
}
disabled={lightboxIndex === null || lightboxIndex <= 0}
style={{
visibility:
lightboxIndex === null || lightboxIndex <= 0 ? 'hidden' : 'visible',
}}
>
<i className="bi bi-chevron-left" aria-hidden="true" />
<span className="visually-hidden">{t('lightbox.prev')}</span>
</Button>
<Button
type="button"
variant="dark"
className="bb-lightbox-btn"
onClick={() =>
setLightboxIndex((prev) =>
prev === null
? prev
: Math.min(lightboxItems.length - 1, prev + 1)
)
}
disabled={
lightboxIndex === null || lightboxIndex >= lightboxItems.length - 1
}
style={{
visibility:
lightboxIndex === null || lightboxIndex >= lightboxItems.length - 1
? 'hidden'
: 'visible',
}}
>
<i className="bi bi-chevron-right" aria-hidden="true" />
<span className="visually-hidden">{t('lightbox.next')}</span>
</Button>
</div>
</Modal.Body>
</Modal>
</Container>
)
}