feat: system tools and admin enhancements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s

This commit is contained in:
2026-01-31 20:12:09 +01:00
parent 64244567c0
commit 9c60a8944e
31 changed files with 3088 additions and 173 deletions

View File

@@ -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>