feat: system tools and admin enhancements
This commit is contained in:
@@ -3,9 +3,13 @@ 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,
|
||||
@@ -34,7 +38,16 @@ export default function ThreadView() {
|
||||
const [previewHtml, setPreviewHtml] = useState('')
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewUrls, setPreviewUrls] = useState([])
|
||||
const [lightboxImage, setLightboxImage] = 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,
|
||||
@@ -458,7 +471,32 @@ export default function ThreadView() {
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
className="bb-attachment-item border-0 text-start"
|
||||
onClick={() => setLightboxImage(attachment.download_url)}
|
||||
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}
|
||||
@@ -516,6 +554,145 @@ export default function ThreadView() {
|
||||
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' })
|
||||
}
|
||||
@@ -725,10 +902,24 @@ export default function ThreadView() {
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-actions">
|
||||
<button type="button" className="bb-post-action" aria-label="Edit post">
|
||||
<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="Delete post">
|
||||
<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">
|
||||
@@ -737,7 +928,13 @@ export default function ThreadView() {
|
||||
<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">
|
||||
<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 && (
|
||||
@@ -752,7 +949,17 @@ export default function ThreadView() {
|
||||
onClick={(event) => {
|
||||
if (event.target?.tagName === 'IMG') {
|
||||
event.preventDefault()
|
||||
setLightboxImage(event.target.src)
|
||||
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 }}
|
||||
@@ -881,15 +1088,176 @@ export default function ThreadView() {
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={Boolean(lightboxImage)}
|
||||
onHide={() => setLightboxImage('')}
|
||||
show={Boolean(editPost)}
|
||||
onHide={() => setEditPost(null)}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Body className="text-center">
|
||||
{lightboxImage && (
|
||||
<img src={lightboxImage} alt="" className="img-fluid rounded" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user