898 lines
44 KiB
JavaScript
898 lines
44 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,
|
|
getThread,
|
|
listPostsByThread,
|
|
updateThreadSolved,
|
|
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 [lightboxImage, setLightboxImage] = 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={() => setLightboxImage(attachment.download_url)}
|
|
>
|
|
<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 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="Edit post">
|
|
<i className="bi bi-pencil" aria-hidden="true" />
|
|
</button>
|
|
<button type="button" className="bb-post-action" aria-label="Delete 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="Quote 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()
|
|
setLightboxImage(event.target.src)
|
|
}
|
|
}}
|
|
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(lightboxImage)}
|
|
onHide={() => setLightboxImage('')}
|
|
centered
|
|
size="lg"
|
|
>
|
|
<Modal.Body className="text-center">
|
|
{lightboxImage && (
|
|
<img src={lightboxImage} alt="" className="img-fluid rounded" />
|
|
)}
|
|
</Modal.Body>
|
|
</Modal>
|
|
</Container>
|
|
)
|
|
}
|