Files
speedBB/resources/js/pages/Acp.jsx

1421 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
import DataTable, { createTheme } from 'react-data-table-component'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import {
createForum,
deleteForum,
fetchSetting,
listAllForums,
listUsers,
reorderForums,
saveSetting,
uploadFavicon,
uploadLogo,
updateForum,
} from '../api/client'
export default function Acp({ isAdmin }) {
const { t } = useTranslation()
const [forums, setForums] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selectedId, setSelectedId] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [overId, setOverId] = useState(null)
const pendingOrder = useRef(null)
const [createType, setCreateType] = useState(null)
const [users, setUsers] = useState([])
const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10)
const [generalSaving, setGeneralSaving] = useState(false)
const [generalUploading, setGeneralUploading] = useState(false)
const [generalError, setGeneralError] = useState('')
const [generalSettings, setGeneralSettings] = useState({
forumName: '',
defaultTheme: 'auto',
darkAccent: '',
lightAccent: '',
darkLogo: '',
lightLogo: '',
showHeaderName: true,
faviconIco: '',
favicon16: '',
favicon32: '',
favicon48: '',
favicon64: '',
favicon128: '',
favicon256: '',
})
const [themeMode, setThemeMode] = useState(
document.documentElement.getAttribute('data-bs-theme') || 'light'
)
useEffect(() => {
createTheme('speedbb-dark', {
text: {
primary: '#e6e8eb',
secondary: '#9aa4b2',
},
background: {
default: 'transparent',
},
context: {
background: '#1a1f29',
text: '#ffffff',
},
divider: {
default: '#2a2f3a',
},
action: {
button: 'rgba(230, 232, 235, 0.12)',
hover: 'rgba(230, 232, 235, 0.08)',
disabled: 'rgba(230, 232, 235, 0.35)',
},
})
createTheme('speedbb-light', {
text: {
primary: '#0e121b',
secondary: '#5b6678',
},
background: {
default: '#ffffff',
},
context: {
background: '#f7f2ea',
text: '#0e121b',
},
divider: {
default: '#e0d7c7',
},
action: {
button: 'rgba(14, 18, 27, 0.12)',
hover: 'rgba(14, 18, 27, 0.06)',
disabled: 'rgba(14, 18, 27, 0.35)',
},
})
}, [])
useEffect(() => {
if (!isAdmin) return
let active = true
const loadSettings = async () => {
try {
const keys = [
'forum_name',
'default_theme',
'accent_color_dark',
'accent_color_light',
'logo_dark',
'logo_light',
'show_header_name',
'favicon_ico',
'favicon_16',
'favicon_32',
'favicon_48',
'favicon_64',
'favicon_128',
'favicon_256',
]
const results = await Promise.all(keys.map((key) => fetchSetting(key)))
if (!active) return
const next = {
forumName: results[0]?.value || '',
defaultTheme: results[1]?.value || 'auto',
darkAccent: results[2]?.value || '',
lightAccent: results[3]?.value || '',
darkLogo: results[4]?.value || '',
lightLogo: results[5]?.value || '',
showHeaderName: results[6]?.value !== 'false',
faviconIco: results[7]?.value || '',
favicon16: results[8]?.value || '',
favicon32: results[9]?.value || '',
favicon48: results[10]?.value || '',
favicon64: results[11]?.value || '',
favicon128: results[12]?.value || '',
favicon256: results[13]?.value || '',
}
setGeneralSettings(next)
} catch (err) {
if (active) setGeneralError(err.message)
}
}
loadSettings()
return () => {
active = false
}
}, [isAdmin])
const handleGeneralSave = async (event) => {
event.preventDefault()
setGeneralSaving(true)
setGeneralError('')
try {
await Promise.all([
saveSetting('forum_name', generalSettings.forumName.trim() || ''),
saveSetting('default_theme', generalSettings.defaultTheme || 'auto'),
saveSetting('accent_color_dark', generalSettings.darkAccent.trim() || ''),
saveSetting('accent_color_light', generalSettings.lightAccent.trim() || ''),
saveSetting('logo_dark', generalSettings.darkLogo.trim() || ''),
saveSetting('logo_light', generalSettings.lightLogo.trim() || ''),
saveSetting('show_header_name', generalSettings.showHeaderName ? 'true' : 'false'),
saveSetting('favicon_ico', generalSettings.faviconIco.trim() || ''),
saveSetting('favicon_16', generalSettings.favicon16.trim() || ''),
saveSetting('favicon_32', generalSettings.favicon32.trim() || ''),
saveSetting('favicon_48', generalSettings.favicon48.trim() || ''),
saveSetting('favicon_64', generalSettings.favicon64.trim() || ''),
saveSetting('favicon_128', generalSettings.favicon128.trim() || ''),
saveSetting('favicon_256', generalSettings.favicon256.trim() || ''),
])
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: {
forumName: generalSettings.forumName.trim() || '',
defaultTheme: generalSettings.defaultTheme || 'auto',
accentDark: generalSettings.darkAccent.trim() || '',
accentLight: generalSettings.lightAccent.trim() || '',
logoDark: generalSettings.darkLogo.trim() || '',
logoLight: generalSettings.lightLogo.trim() || '',
showHeaderName: generalSettings.showHeaderName,
faviconIco: generalSettings.faviconIco.trim() || '',
favicon16: generalSettings.favicon16.trim() || '',
favicon32: generalSettings.favicon32.trim() || '',
favicon48: generalSettings.favicon48.trim() || '',
favicon64: generalSettings.favicon64.trim() || '',
favicon128: generalSettings.favicon128.trim() || '',
favicon256: generalSettings.favicon256.trim() || '',
},
})
)
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralSaving(false)
}
}
const handleDefaultThemeChange = async (value) => {
const previous = generalSettings.defaultTheme
setGeneralSettings((prev) => ({ ...prev, defaultTheme: value }))
setGeneralError('')
try {
await saveSetting('default_theme', value)
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { defaultTheme: value },
})
)
} catch (err) {
setGeneralSettings((prev) => ({ ...prev, defaultTheme: previous }))
setGeneralError(err.message)
}
}
const handleLogoUpload = async (file, variantKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadLogo(file)
const url = result?.url || ''
const settingKey = variantKey === 'darkLogo' ? 'logo_dark' : 'logo_light'
setGeneralSettings((prev) => ({ ...prev, [variantKey]: url }))
if (url) {
await saveSetting(settingKey, url)
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const handleFaviconUpload = async (file, settingKey, stateKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadFavicon(file)
const url = result?.url || ''
setGeneralSettings((prev) => ({ ...prev, [stateKey]: url }))
if (url) {
await saveSetting(settingKey, url)
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { [stateKey]: url },
})
)
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const faviconIcoDropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico', 'faviconIco'),
})
const favicon16Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16', 'favicon16'),
})
const favicon32Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32', 'favicon32'),
})
const favicon48Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48', 'favicon48'),
})
const favicon64Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64', 'favicon64'),
})
const favicon128Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128', 'favicon128'),
})
const favicon256Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256', 'favicon256'),
})
const darkLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'darkLogo'),
})
const lightLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'lightLogo'),
})
useEffect(() => {
const observer = new MutationObserver(() => {
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-bs-theme'],
})
return () => observer.disconnect()
}, [])
const userColumns = useMemo(
() => [
{
name: t('user.name'),
selector: (row) => row.name,
sortable: true,
},
{
name: t('user.email'),
selector: (row) => row.email,
sortable: true,
},
{
name: '',
width: '180px',
right: true,
cell: (row) => (
<div className="bb-user-actions">
<ButtonGroup className="bb-action-group">
<Button
variant="dark"
title={t('user.impersonate')}
onClick={() => console.log('impersonate', row)}
>
<i className="bi bi-person-badge" aria-hidden="true" />
</Button>
<Button
variant="dark"
title={t('user.edit')}
onClick={() => console.log('edit user', row)}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
variant="dark"
title={t('user.delete')}
onClick={() => console.log('delete user', row)}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
),
},
],
[t]
)
const userTableStyles = useMemo(
() => ({
table: {
style: {
backgroundColor: themeMode === 'dark' ? 'transparent' : '#ffffff',
},
},
headRow: {
style: {
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#f7f2ea',
borderBottomColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
fontWeight: 600,
},
},
rows: {
style: {
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : '#ffffff',
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
},
stripedStyle: {
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#f7f2ea',
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
},
highlightOnHoverStyle: {
backgroundColor: themeMode === 'dark'
? 'transparent'
: 'rgba(0, 0, 0, 0.04)',
},
},
pagination: {
style: {
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#ffffff',
color: themeMode === 'dark' ? '#cfd6df' : '#5b6678',
borderTopColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
},
},
}),
[themeMode]
)
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
const current = Math.min(usersPage, totalPages)
const pages = []
for (let page = Math.max(1, current - 2); page <= Math.min(totalPages, current + 2); page += 1) {
pages.push(page)
}
const goTo = (page) => {
if (page < 1 || page > totalPages) return
onChangePage(page, rowCount)
}
return (
<div className="bb-pagination">
<div className="bb-pagination-range">
{t('table.rows_per_page')} {rowsPerPage}{' '}
<span className="bb-muted">
{((current - 1) * rowsPerPage) + 1}-{Math.min(current * rowsPerPage, rowCount)} {t('table.range_separator')} {rowCount}
</span>
</div>
<div className="bb-pagination-actions">
<button type="button" onClick={() => goTo(1)} disabled={current === 1}>
«
</button>
<button type="button" onClick={() => goTo(current - 1)} disabled={current === 1}>
</button>
{pages.map((page) => (
<button
key={page}
type="button"
className={page === current ? 'is-active' : ''}
onClick={() => goTo(page)}
>
{page}
</button>
))}
<button type="button" onClick={() => goTo(current + 1)} disabled={current === totalPages}>
</button>
<button type="button" onClick={() => goTo(totalPages)} disabled={current === totalPages}>
»
</button>
</div>
</div>
)
}
const [collapsed, setCollapsed] = useState(() => new Set())
const hasInitializedCollapse = useRef(false)
const [showModal, setShowModal] = useState(false)
const [form, setForm] = useState({
name: '',
description: '',
type: 'category',
parentId: '',
})
const refreshForums = async () => {
setLoading(true)
setError('')
try {
const data = await listAllForums()
setForums(data.filter((forum) => !forum.deleted_at))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshForums()
}
}, [isAdmin])
useEffect(() => {
if (!hasInitializedCollapse.current && forums.length > 0) {
const ids = forums
.filter((forum) => forum.type === 'category')
.map((forum) => String(forum.id))
setCollapsed(new Set(ids))
hasInitializedCollapse.current = true
}
}, [forums])
const refreshUsers = async () => {
setUsersLoading(true)
setUsersError('')
try {
const data = await listUsers()
setUsers(data)
} catch (err) {
setUsersError(err.message)
} finally {
setUsersLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshUsers()
}
}, [isAdmin])
const getParentId = (forum) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
})
forums.forEach((forum) => {
const parentId = getParentId(forum)
const node = map.get(String(forum.id))
if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
const sortNodes = (nodes) => {
nodes.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.name.localeCompare(b.name)
})
nodes.forEach((node) => sortNodes(node.children))
}
sortNodes(roots)
return { roots, map }
}, [forums])
const categoryOptions = useMemo(
() => forums.filter((forum) => forum.type === 'category'),
[forums]
)
const handleSelectForum = (forum) => {
const parentId =
typeof forum.parent === 'string'
? forum.parent.split('/').pop()
: forum.parent?.id ?? ''
setSelectedId(String(forum.id))
setShowModal(true)
setCreateType(null)
setForm({
name: forum.name || '',
description: forum.description || '',
type: forum.type || 'category',
parentId: parentId ? String(parentId) : '',
})
}
const handleReset = () => {
setSelectedId(null)
setShowModal(false)
setCreateType(null)
setForm({
name: '',
description: '',
type: 'category',
parentId: '',
})
}
const isExpanded = (forumId) => {
const key = String(forumId)
return !collapsed.has(key)
}
const toggleExpanded = (forumId) => {
const key = String(forumId)
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const handleCollapseAll = () => {
const ids = forums
.filter((forum) => forum.type === 'category')
.map((forum) => String(forum.id))
setCollapsed(new Set(ids))
}
const handleExpandAll = () => {
setCollapsed(new Set())
}
const handleStartCreate = (type) => {
const current = selectedId
setSelectedId(null)
setShowModal(true)
setCreateType(type)
const parentFromSelection = current
? forums.find((forum) => String(forum.id) === String(current))
: null
const parentId =
parentFromSelection?.type === 'category' ? String(parentFromSelection.id) : ''
setForm({
name: '',
description: '',
type,
parentId,
})
}
const handleStartCreateChild = (type, parentId) => {
setSelectedId(null)
setShowModal(true)
setCreateType(type)
setForm({
name: '',
description: '',
type,
parentId: parentId ? String(parentId) : '',
})
}
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
const trimmedName = form.name.trim()
if (!trimmedName) {
setError(t('acp.forums_name_required'))
return
}
if (form.type === 'forum' && !form.parentId) {
setError(t('acp.forums_parent_required'))
return
}
try {
if (selectedId) {
await updateForum(selectedId, {
name: trimmedName,
description: form.description,
type: form.type,
parentId: form.parentId || null,
})
} else {
await createForum({
name: trimmedName,
description: form.description,
type: form.type,
parentId: form.parentId || null,
})
}
handleReset()
refreshForums()
} catch (err) {
setError(err.message)
}
}
const handleDelete = async (forumId) => {
setError('')
if (!confirm(t('acp.forums_confirm_delete'))) {
return
}
try {
await deleteForum(forumId)
if (selectedId === String(forumId)) {
handleReset()
}
refreshForums()
} catch (err) {
setError(err.message)
}
}
const handleDragStart = (event, forumId) => {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(forumId))
setDraggingId(String(forumId))
}
const handleDragEnd = () => {
if (pendingOrder.current) {
const { parentId, ordered } = pendingOrder.current
pendingOrder.current = null
reorderForums(parentId, ordered).catch((err) => setError(err.message))
}
setDraggingId(null)
setOverId(null)
}
const applyLocalOrder = (parentId, orderedIds) => {
setForums((prev) =>
prev.map((forum) => {
const pid = getParentId(forum)
if (String(pid ?? '') !== String(parentId ?? '')) {
return forum
}
const newIndex = orderedIds.indexOf(String(forum.id))
return newIndex === -1 ? forum : { ...forum, position: newIndex + 1 }
})
)
}
const handleDragOver = (event, targetId, parentId) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
if (!draggingId || String(draggingId) === String(targetId)) {
return
}
const draggedForum = forums.find((forum) => String(forum.id) === String(draggingId))
if (!draggedForum) {
return
}
const draggedParentId = getParentId(draggedForum)
if (String(draggedParentId ?? '') !== String(parentId ?? '')) {
return
}
const siblings = forums.filter((forum) => {
const pid = getParentId(forum)
return String(pid ?? '') === String(parentId ?? '')
})
const ordered = siblings
.slice()
.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.name.localeCompare(b.name)
})
.map((forum) => String(forum.id))
const fromIndex = ordered.indexOf(String(draggingId))
const toIndex = ordered.indexOf(String(targetId))
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return
}
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
setOverId(String(targetId))
applyLocalOrder(parentId, ordered)
pendingOrder.current = { parentId, ordered }
}
const handleDragEnter = (forumId) => {
if (draggingId && String(forumId) !== String(draggingId)) {
setOverId(String(forumId))
}
}
const handleDragLeave = (event, forumId) => {
if (event.currentTarget.contains(event.relatedTarget)) {
return
}
if (overId === String(forumId)) {
setOverId(null)
}
}
const handleDrop = async (event, targetId, parentId) => {
event.preventDefault()
const draggedId = event.dataTransfer.getData('text/plain')
if (!draggedId || String(draggedId) === String(targetId)) {
setDraggingId(null)
setOverId(null)
return
}
const siblings = forums.filter((forum) => {
const pid = getParentId(forum)
return String(pid ?? '') === String(parentId ?? '')
})
const ordered = siblings
.slice()
.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.name.localeCompare(b.name)
})
.map((forum) => String(forum.id))
const fromIndex = ordered.indexOf(String(draggedId))
const toIndex = ordered.indexOf(String(targetId))
if (fromIndex === -1 || toIndex === -1) {
return
}
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
pendingOrder.current = null
try {
await reorderForums(parentId, ordered)
const updated = forums.map((forum) => {
const pid = getParentId(forum)
if (String(pid ?? '') !== String(parentId ?? '')) {
return forum
}
const newIndex = ordered.indexOf(String(forum.id))
return newIndex === -1 ? forum : { ...forum, position: newIndex + 1 }
})
setForums(updated)
} catch (err) {
setError(err.message)
} finally {
setDraggingId(null)
setOverId(null)
}
}
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
<div
className={`bb-drag-item d-flex align-items-center justify-content-between border rounded p-2 mb-2 ${
overId === String(node.id) ? 'bb-drop-target' : ''
} ${draggingId === String(node.id) ? 'bb-dragging' : ''}`}
style={{ marginLeft: depth * 16 }}
draggable
onDragStart={(event) => handleDragStart(event, node.id)}
onDragEnd={handleDragEnd}
onDragOver={(event) => handleDragOver(event, node.id, getParentId(node))}
onDragEnter={() => handleDragEnter(node.id)}
onDragLeave={(event) => handleDragLeave(event, node.id)}
onDrop={(event) => handleDrop(event, node.id, getParentId(node))}
>
<div className="d-flex align-items-start gap-3">
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
{node.type === 'category' && node.children?.length > 0 && (
<button
type="button"
className="bb-collapse-toggle"
onClick={() => toggleExpanded(node.id)}
aria-label={isExpanded(node.id) ? t('acp.collapse') : t('acp.expand')}
title={isExpanded(node.id) ? t('acp.collapse') : t('acp.expand')}
>
<i
className={`bi ${
isExpanded(node.id) ? 'bi-caret-down-fill' : 'bi-caret-right-fill'
}`}
aria-hidden="true"
/>
</button>
)}
</span>
<div>
<div className="fw-semibold d-flex align-items-center gap-2">
<span>{node.name}</span>
</div>
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
</div>
</div>
<div className="d-flex align-items-center gap-3">
<span
className="bb-drag-handle text-muted"
style={{ cursor: 'grab', display: 'inline-flex' }}
title={t('acp.drag_handle')}
>
<i className="bi bi-arrow-down-up" aria-hidden="true" />
</span>
<ButtonGroup size="sm" className="bb-action-group">
{node.type === 'category' && (
<>
<Button
variant="dark"
onClick={() => handleStartCreateChild('category', node.id)}
title={t('acp.add_category')}
>
<i className="bi bi-folder-plus" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleStartCreateChild('forum', node.id)}
title={t('acp.add_forum')}
>
<i className="bi bi-chat-left-text" aria-hidden="true" />
</Button>
</>
)}
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button variant="dark" onClick={() => handleDelete(node.id)} title={t('acp.delete')}>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</div>
{node.children?.length > 0 &&
(!node.type || node.type !== 'category' || isExpanded(node.id)) && (
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
)}
</div>
))
if (!isAdmin) {
return (
<Container className="py-5">
<h2 className="mb-3">{t('acp.title')}</h2>
<p className="bb-muted">{t('acp.no_access')}</p>
</Container>
)
}
return (
<Container fluid className="bb-acp py-4">
<h2 className="mb-4">{t('acp.title')}</h2>
<Tabs defaultActiveKey="general" className="mb-3">
<Tab eventKey="general" title={t('acp.general')}>
<p className="bb-muted">{t('acp.general_hint')}</p>
{generalError && <p className="text-danger">{generalError}</p>}
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
<Row className="g-3">
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.forum_name')}</Form.Label>
<Form.Control
type="text"
value={generalSettings.forumName}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forumName: event.target.value }))
}
/>
</Form.Group>
<Form.Group className="mt-2">
<Form.Check
type="checkbox"
id="acp-show-header-name"
label={t('acp.show_header_name')}
checked={generalSettings.showHeaderName}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
showHeaderName: event.target.checked,
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.defaultTheme}
onChange={(event) => handleDefaultThemeChange(event.target.value)}
>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_dark')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.darkAccent}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
darkAccent: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.darkAccent || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
darkAccent: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_light')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.lightAccent}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
lightAccent: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.lightAccent || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
lightAccent: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_dark')}</Form.Label>
<div
{...darkLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...darkLogoDropzone.getInputProps()} />
{generalSettings.darkLogo ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.darkLogo} alt={t('acp.logo_dark')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_light')}</Form.Label>
<div
{...lightLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...lightLogoDropzone.getInputProps()} />
{generalSettings.lightLogo ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.lightLogo} alt={t('acp.logo_light')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col xs={12}>
<Accordion className="bb-acp-accordion">
<Accordion.Item eventKey="favicons">
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
<Accordion.Body>
<Row className="g-3">
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...faviconIcoDropzone.getInputProps()} />
{generalSettings.faviconIco ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.faviconIco} alt={t('acp.favicon_ico')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_16')}</Form.Label>
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon16Dropzone.getInputProps()} />
{generalSettings.favicon16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon16} alt={t('acp.favicon_16')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_32')}</Form.Label>
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon32Dropzone.getInputProps()} />
{generalSettings.favicon32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon32} alt={t('acp.favicon_32')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_48')}</Form.Label>
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon48Dropzone.getInputProps()} />
{generalSettings.favicon48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon48} alt={t('acp.favicon_48')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_64')}</Form.Label>
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon64Dropzone.getInputProps()} />
{generalSettings.favicon64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon64} alt={t('acp.favicon_64')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_128')}</Form.Label>
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon128Dropzone.getInputProps()} />
{generalSettings.favicon128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon128} alt={t('acp.favicon_128')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_256')}</Form.Label>
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon256Dropzone.getInputProps()} />
{generalSettings.favicon256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon256} alt={t('acp.favicon_256')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
</Row>
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Col>
<Col xs={12} className="d-flex justify-content-end">
<Button
type="submit"
className="bb-accent-button"
disabled={generalSaving || generalUploading}
>
{generalSaving ? t('form.saving') : t('acp.save')}
</Button>
</Col>
</Row>
</Form>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>
{error && <p className="text-danger">{error}</p>}
<Row className="g-4">
<Col lg={12}>
<div className="d-flex align-items-center justify-content-between mb-3 gap-3 flex-wrap">
<div className="d-flex align-items-center gap-2">
<h5 className="mb-0">{t('acp.forums_tree')}</h5>
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleExpandAll}>
<i className="bi bi-arrows-expand me-1" aria-hidden="true" />
{t('acp.expand_all')}
</Button>
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleCollapseAll}>
<i className="bi bi-arrows-collapse me-1" aria-hidden="true" />
{t('acp.collapse_all')}
</Button>
</div>
<div className="d-flex gap-2">
<Button
size="sm"
variant="outline-dark"
className={`bb-acp-action ${createType === 'category' ? 'bb-acp-action--active' : ''}`}
onClick={() => handleStartCreate('category')}
>
<i className="bi bi-folder2 me-1" aria-hidden="true" />
{t('acp.new_category')}
</Button>
<Button
size="sm"
variant="outline-dark"
className={`bb-acp-action ${createType === 'forum' ? 'bb-acp-action--active' : ''}`}
onClick={() => handleStartCreate('forum')}
>
<i className="bi bi-chat-left-text me-1" aria-hidden="true" />
{t('acp.new_forum')}
</Button>
</div>
</div>
{loading && <p className="bb-muted">{t('acp.loading')}</p>}
{!loading && forumTree.roots.length === 0 && (
<p className="bb-muted">{t('acp.forums_empty')}</p>
)}
{forumTree.roots.length > 0 && (
<div className="mt-2">{renderTree(forumTree.roots)}</div>
)}
</Col>
</Row>
</Tab>
<Tab eventKey="users" title={t('acp.users')}>
{usersError && <p className="text-danger">{usersError}</p>}
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!usersLoading && (
<DataTable
columns={userColumns}
data={users}
pagination
striped
highlightOnHover={themeMode !== 'dark'}
dense
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
customStyles={userTableStyles}
paginationComponentOptions={{
rowsPerPageText: t('table.rows_per_page'),
rangeSeparatorText: t('table.range_separator'),
}}
paginationPerPage={usersPerPage}
onChangePage={(page) => setUsersPage(page)}
onChangeRowsPerPage={(perPage) => {
setUsersPerPage(perPage)
setUsersPage(1)
}}
paginationComponent={UsersPagination}
/>
)}
</Tab>
</Tabs>
<Modal show={showModal} onHide={handleReset} centered size="lg">
<Modal.Header closeButton closeVariant="white">
<Modal.Title>
{selectedId
? form.type === 'category'
? t('acp.forums_edit_category_title')
: t('acp.forums_edit_forum_title')
: createType === 'category'
? t('acp.forums_create_category_title')
: createType === 'forum'
? t('acp.forums_create_forum_title')
: t('acp.forums_create_title')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="bb-muted">
{selectedId
? form.type === 'category'
? t('acp.forums_edit_category_hint')
: t('acp.forums_edit_forum_hint')
: createType === 'category'
? t('acp.forums_create_category_hint')
: createType === 'forum'
? t('acp.forums_create_forum_hint')
: t('acp.forums_form_hint')}
</p>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.description')}</Form.Label>
<Form.Control
as="textarea"
rows={3}
value={form.description}
onChange={(event) => setForm({ ...form, description: event.target.value })}
/>
</Form.Group>
{selectedId && (
<Form.Group className="mb-3">
<Form.Label>{t('acp.forums_type')}</Form.Label>
<Form.Select
value={form.type}
onChange={(event) => setForm({ ...form, type: event.target.value })}
>
<option value="category">{t('forum.type_category')}</option>
<option value="forum">{t('forum.type_forum')}</option>
</Form.Select>
</Form.Group>
)}
<Form.Group className="mb-3">
<Form.Label>{t('acp.forums_parent')}</Form.Label>
<Form.Select
value={form.parentId}
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
>
<option value="" disabled={form.type === 'forum'}>
{t('acp.forums_parent_root')}
</option>
{categoryOptions
.filter((option) => String(option.id) !== String(selectedId))
.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</Form.Select>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Button type="button" variant="outline-secondary" onClick={handleReset}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button">
{selectedId ? t('acp.save') : t('acp.create')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
</Container>
)
}