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) => (
),
},
],
[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 (
{t('table.rows_per_page')} {rowsPerPage}{' '}
{((current - 1) * rowsPerPage) + 1}-{Math.min(current * rowsPerPage, rowCount)} {t('table.range_separator')} {rowCount}
{pages.map((page) => (
))}
)
}
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) => (
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))}
>
{node.type === 'category' && node.children?.length > 0 && (
)}
{node.name}
{node.description || t('forum.no_description')}
{node.type === 'category' && (
<>
>
)}
{node.children?.length > 0 &&
(!node.type || node.type !== 'category' || isExpanded(node.id)) && (
{renderTree(node.children, depth + 1)}
)}
))
if (!isAdmin) {
return (
{t('acp.title')}
{t('acp.no_access')}
)
}
return (
{t('acp.title')}
{t('acp.general_hint')}
{generalError && {generalError}
}
{t('acp.forums_hint')}
{error && {error}
}
{t('acp.forums_tree')}
{loading && {t('acp.loading')}
}
{!loading && forumTree.roots.length === 0 && (
{t('acp.forums_empty')}
)}
{forumTree.roots.length > 0 && (
{renderTree(forumTree.roots)}
)}
{usersError && {usersError}
}
{usersLoading && {t('acp.loading')}
}
{!usersLoading && (
setUsersPage(page)}
onChangeRowsPerPage={(perPage) => {
setUsersPerPage(perPage)
setUsersPage(1)
}}
paginationComponent={UsersPagination}
/>
)}
{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')}
{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')}
{t('form.title')}
setForm({ ...form, name: event.target.value })}
required
/>
{t('form.description')}
setForm({ ...form, description: event.target.value })}
/>
{selectedId && (
{t('acp.forums_type')}
setForm({ ...form, type: event.target.value })}
>
)}
{t('acp.forums_parent')}
setForm({ ...form, parentId: event.target.value })}
>
{categoryOptions
.filter((option) => String(option.id) !== String(selectedId))
.map((option) => (
))}
)
}