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 { useAuth } from '../context/AuthContext'
import {
createForum,
deleteForum,
fetchSettings,
listAllForums,
listRanks,
listRoles,
listUsers,
reorderForums,
saveSetting,
saveSettings,
createRank,
deleteRank,
updateUserRank,
updateRank,
updateUser,
createRole,
updateRole,
deleteRole,
uploadRankBadgeImage,
uploadFavicon,
uploadLogo,
updateForum,
} from '../api/client'
export default function Acp({ isAdmin }) {
const { t } = useTranslation()
const { roles: authRoles } = useAuth()
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
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 [userSearch, setUserSearch] = useState('')
const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
const [ranks, setRanks] = useState([])
const [ranksLoading, setRanksLoading] = useState(false)
const [ranksError, setRanksError] = useState('')
const [rankUpdatingId, setRankUpdatingId] = useState(null)
const [rankFormName, setRankFormName] = useState('')
const [rankFormType, setRankFormType] = useState('text')
const [rankFormText, setRankFormText] = useState('')
const [rankFormColor, setRankFormColor] = useState('')
const [rankFormImage, setRankFormImage] = useState(null)
const [rankSaving, setRankSaving] = useState(false)
const [showRankCreate, setShowRankCreate] = useState(false)
const [showRankModal, setShowRankModal] = useState(false)
const [rankEdit, setRankEdit] = useState({
id: null,
name: '',
badgeType: 'text',
badgeText: '',
badgeImageUrl: '',
color: '',
})
const [rankEditImage, setRankEditImage] = useState(null)
const [showUserModal, setShowUserModal] = useState(false)
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
const [roleQuery, setRoleQuery] = useState('')
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
const roleMenuRef = useRef(null)
const [roles, setRoles] = useState([])
const [rolesLoading, setRolesLoading] = useState(false)
const [rolesError, setRolesError] = useState('')
const [roleFormName, setRoleFormName] = useState('')
const [roleFormColor, setRoleFormColor] = useState('')
const [roleSaving, setRoleSaving] = useState(false)
const [showRoleCreate, setShowRoleCreate] = useState(false)
const [showRoleModal, setShowRoleModal] = useState(false)
const [roleEdit, setRoleEdit] = useState({
id: null,
name: '',
originalName: '',
color: '',
isCore: false,
})
const [userSaving, setUserSaving] = useState(false)
const [generalSaving, setGeneralSaving] = useState(false)
const [generalUploading, setGeneralUploading] = useState(false)
const [generalError, setGeneralError] = useState('')
const [generalSettings, setGeneralSettings] = useState({
forum_name: '',
default_theme: 'auto',
accent_color_dark: '',
accent_color_light: '',
logo_dark: '',
logo_light: '',
show_header_name: 'true',
favicon_ico: '',
favicon_16: '',
favicon_32: '',
favicon_48: '',
favicon_64: '',
favicon_128: '',
favicon_256: '',
})
const settingsDetailMap = {
forum_name: 'forumName',
default_theme: 'defaultTheme',
accent_color_dark: 'accentDark',
accent_color_light: 'accentLight',
logo_dark: 'logoDark',
logo_light: 'logoLight',
show_header_name: 'showHeaderName',
favicon_ico: 'faviconIco',
favicon_16: 'favicon16',
favicon_32: 'favicon32',
favicon_48: 'favicon48',
favicon_64: 'favicon64',
favicon_128: 'favicon128',
favicon_256: '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 allSettings = await fetchSettings()
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
if (!active) return
const next = {
forum_name: settingsMap.get('forum_name') || '',
default_theme: settingsMap.get('default_theme') || 'auto',
accent_color_dark: settingsMap.get('accent_color_dark') || '',
accent_color_light: settingsMap.get('accent_color_light') || '',
logo_dark: settingsMap.get('logo_dark') || '',
logo_light: settingsMap.get('logo_light') || '',
show_header_name: settingsMap.get('show_header_name') || 'true',
favicon_ico: settingsMap.get('favicon_ico') || '',
favicon_16: settingsMap.get('favicon_16') || '',
favicon_32: settingsMap.get('favicon_32') || '',
favicon_48: settingsMap.get('favicon_48') || '',
favicon_64: settingsMap.get('favicon_64') || '',
favicon_128: settingsMap.get('favicon_128') || '',
favicon_256: settingsMap.get('favicon_256') || '',
}
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 saveSettings(
Object.entries(generalSettings).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value.trim() : String(value ?? ''),
}))
)
const detail = Object.entries(generalSettings).reduce((acc, [key, value]) => {
const mappedKey = settingsDetailMap[key]
if (!mappedKey) return acc
if (key === 'show_header_name') {
acc[mappedKey] = value !== 'false'
return acc
}
acc[mappedKey] = typeof value === 'string' ? value.trim() : String(value ?? '')
return acc
}, {})
window.dispatchEvent(new CustomEvent('speedbb-settings-updated', { detail }))
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralSaving(false)
}
}
const handleDefaultThemeChange = async (value) => {
const previous = generalSettings.default_theme
setGeneralSettings((prev) => ({ ...prev, default_theme: value }))
setGeneralError('')
try {
await saveSetting('default_theme', value)
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { defaultTheme: value },
})
)
} catch (err) {
setGeneralSettings((prev) => ({ ...prev, default_theme: previous }))
setGeneralError(err.message)
}
}
const handleLogoUpload = async (file, settingKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadLogo(file)
const url = result?.url || ''
setGeneralSettings((prev) => ({ ...prev, [settingKey]: url }))
if (url) {
await saveSetting(settingKey, url)
const mappedKey = settingsDetailMap[settingKey]
if (mappedKey) {
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { [mappedKey]: url },
})
)
}
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const handleFaviconUpload = async (file, settingKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadFavicon(file)
const url = result?.url || ''
setGeneralSettings((prev) => ({ ...prev, [settingKey]: url }))
if (url) {
await saveSetting(settingKey, url)
const mappedKey = settingsDetailMap[settingKey]
if (mappedKey) {
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { [mappedKey]: 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'),
})
const favicon16Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16'),
})
const favicon32Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32'),
})
const favicon48Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48'),
})
const favicon64Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64'),
})
const favicon128Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128'),
})
const favicon256Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256'),
})
const darkLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'logo_dark'),
})
const lightLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'logo_light'),
})
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 filteredUsers = useMemo(() => {
const term = userSearch.trim().toLowerCase()
if (!term) return users
return users.filter((user) =>
[user.name, user.email, user.rank?.name]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(term))
)
}, [users, userSearch])
const userColumns = useMemo(
() => {
const iconFor = (id) => {
if (userSort.columnId !== id) {
return 'bi-arrow-down-up'
}
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
}
return [
{
id: 'name',
name: (
{t('user.name')}
),
selector: (row) => row.name,
sortable: true,
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
sensitivity: 'base',
}),
},
{
id: 'email',
name: (
{t('user.email')}
),
selector: (row) => row.email,
sortable: true,
},
{
id: 'rank',
name: (
{t('user.rank')}
),
width: '220px',
sortable: true,
sortFunction: (a, b) =>
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
cell: (row) => (
{
const nextRankId = event.target.value ? Number(event.target.value) : null
setRankUpdatingId(row.id)
try {
const updated = await updateUserRank(row.id, nextRankId)
setUsers((prev) =>
prev.map((user) =>
user.id === row.id ? { ...user, rank: updated.rank } : user
)
)
} catch (err) {
setUsersError(err.message)
} finally {
setRankUpdatingId(null)
}
}}
>
{ranks.map((rank) => (
))}
),
},
{
name: '',
width: '180px',
right: true,
cell: (row) => (
{(() => {
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
return (
)
})()}
),
},
]
},
[t, ranks, ranksLoading, rankUpdatingId, userSort]
)
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])
useEffect(() => {
if (!roleMenuOpen) return
const handleClick = (event) => {
if (!roleMenuRef.current) return
if (!roleMenuRef.current.contains(event.target)) {
setRoleMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [roleMenuOpen])
useEffect(() => {
if (!isAdmin) return
let active = true
const loadRoles = async () => {
setRolesLoading(true)
setRolesError('')
try {
const data = await listRoles()
if (active) setRoles(data)
} catch (err) {
if (active) setRolesError(err.message)
} finally {
if (active) setRolesLoading(false)
}
}
loadRoles()
return () => {
active = false
}
}, [isAdmin])
const refreshRanks = async () => {
setRanksLoading(true)
setRanksError('')
try {
const data = await listRanks()
setRanks(data)
} catch (err) {
setRanksError(err.message)
} finally {
setRanksLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshRanks()
}
}, [isAdmin])
const handleCreateRank = async (event) => {
if (event?.preventDefault) {
event.preventDefault()
}
if (!rankFormName.trim()) return
if (rankFormType === 'image' && !rankFormImage) {
setRanksError(t('rank.badge_image_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const created = await createRank({
name: rankFormName.trim(),
badge_type: rankFormType,
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
color: rankFormColor.trim() || null,
})
let next = created
if (rankFormType === 'image' && rankFormImage) {
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
next = { ...created, ...updated }
}
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
setRankFormName('')
setRankFormType('text')
setRankFormText('')
setRankFormColor('')
setRankFormImage(null)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}
const isCoreRole = (name) => name === 'ROLE_ADMIN' || name === 'ROLE_USER' || name === 'ROLE_FOUNDER'
const formatRoleLabel = (name) => {
if (!name) return ''
const withoutPrefix = name.startsWith('ROLE_') ? name.slice(5) : name
return withoutPrefix
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
const filteredRoles = useMemo(() => {
const query = roleQuery.trim().toLowerCase()
if (!query) return roles
return roles.filter((role) =>
formatRoleLabel(role.name).toLowerCase().includes(query)
)
}, [roles, roleQuery])
const handleCreateRole = async (event) => {
event.preventDefault()
if (!roleFormName.trim()) return
setRoleSaving(true)
setRolesError('')
try {
const created = await createRole({
name: roleFormName.trim(),
color: roleFormColor.trim() || null,
})
setRoles((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
setRoleFormName('')
setRoleFormColor('')
setShowRoleCreate(false)
} catch (err) {
setRolesError(err.message)
} finally {
setRoleSaving(false)
}
}
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 || ''}
{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}
}
{ranksError && {ranksError}
}
{usersLoading && {t('acp.loading')}
}
{!usersLoading && (
}
defaultSortFieldId="name"
onSort={(column, direction) => {
setUserSort({ columnId: column.id, direction })
}}
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}
subHeader
subHeaderComponent={
setUserSearch(event.target.value)}
placeholder={t('user.search')}
/>
}
/>
)}
{rolesError && {rolesError}
}
{rolesLoading && {t('acp.loading')}
}
{!rolesLoading && roles.length === 0 && (
{t('group.empty')}
)}
{!rolesLoading && roles.length > 0 && (
{roles.map((role) => {
const coreRole = isCoreRole(role.name)
return (
{role.color && (
)}
{formatRoleLabel(role.name)}
)
})}
)}
{ranksError && {ranksError}
}
{ranksLoading && {t('acp.loading')}
}
{!ranksLoading && ranks.length === 0 && (
{t('rank.empty')}
)}
{!ranksLoading && ranks.length > 0 && (
{ranks.map((rank) => (
{rank.color && (
)}
{rank.name}
{rank.badge_type === 'image' && rank.badge_image_url && (

)}
{rank.badge_type !== 'image' && rank.badge_text && (
{rank.badge_text}
)}
))}
)}
{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) => (
))}
setShowUserModal(false)}
centered
>
{t('user.edit_title')}
{usersError && {usersError}
}
{t('form.username')}
setUserForm((prev) => ({ ...prev, name: event.target.value }))
}
required
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
/>
{t('form.email')}
setUserForm((prev) => ({ ...prev, email: event.target.value }))
}
required
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
/>
{t('user.rank')}
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
}
disabled={ranksLoading || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
>
{ranks.map((rank) => (
))}
{t('user.roles')}
{roleMenuOpen && (
setRoleQuery(event.target.value)}
placeholder={t('user.search')}
/>
{filteredRoles.length === 0 && (
{t('rank.empty')}
)}
{filteredRoles.map((role) => {
const isSelected = (userForm.roles || []).includes(role.name)
const isFounderRole = role.name === 'ROLE_FOUNDER'
const isLocked = isFounderRole && !canManageFounder
return (
)
})}
)}
setShowRoleModal(false)}
centered
>
{t('group.edit_title')}
{rolesError && {rolesError}
}
{t('group.name')}
setRoleEdit((prev) => ({ ...prev, name: event.target.value }))
}
disabled={roleEdit.isCore}
required
/>
{t('group.color')}
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
}
placeholder={t('group.color_placeholder')}
/>
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
}
/>
setShowRoleCreate(false)}
centered
>
{t('group.create_title')}
{rolesError && {rolesError}
}
{t('group.name')}
setRoleFormName(event.target.value)}
placeholder={t('group.name_placeholder')}
disabled={roleSaving}
/>
{t('group.color')}
setRoleFormColor(event.target.value)}
placeholder={t('group.color_placeholder')}
disabled={roleSaving}
/>
setRoleFormColor(event.target.value)}
disabled={roleSaving}
/>
setShowRankModal(false)}
centered
>
{t('rank.edit_title')}
{ranksError && {ranksError}
}
{t('rank.name')}
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
{t('rank.color')}
setRankEdit((prev) => ({
...prev,
color: event.target.checked ? '' : prev.color || '#f29b3f',
}))
}
/>
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
}
placeholder={t('rank.color_placeholder')}
disabled={!rankEdit.color}
/>
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
}
disabled={!rankEdit.color}
/>
{t('rank.badge_type')}
setRankEdit((prev) => ({ ...prev, badgeType: 'none' }))
}
/>
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
}
/>
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
}
/>
{rankEdit.badgeType === 'text' && (
{t('rank.badge_text')}
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
}
placeholder={t('rank.badge_text_placeholder')}
required
/>
)}
{rankEdit.badgeType === 'image' && (
{t('rank.badge_image')}
{rankEdit.badgeImageUrl && !rankEditImage && (
)}
setRankEditImage(event.target.files?.[0] || null)}
/>
)}
setShowRankCreate(false)}
centered
>
{t('rank.create_title')}
{ranksError && {ranksError}
}
{t('rank.name')}
setRankFormName(event.target.value)}
placeholder={t('rank.name_placeholder')}
disabled={rankSaving}
/>
{t('rank.color')}
setRankFormColor(event.target.checked ? '' : '#f29b3f')
}
disabled={rankSaving}
/>
setRankFormColor(event.target.value)}
placeholder={t('rank.color_placeholder')}
disabled={rankSaving || !rankFormColor}
/>
setRankFormColor(event.target.value)}
disabled={rankSaving || !rankFormColor}
/>
{t('rank.badge_type')}
setRankFormType('none')}
/>
setRankFormType('text')}
/>
setRankFormType('image')}
/>
{rankFormType === 'text' && (
{t('rank.badge_text')}
setRankFormText(event.target.value)}
placeholder={t('rank.badge_text_placeholder')}
disabled={rankSaving}
/>
)}
{rankFormType === 'image' && (
{t('rank.badge_image')}
setRankFormImage(event.target.files?.[0] || null)}
disabled={rankSaving}
/>
)}
)
}