Files
speedBB/resources/js/pages/Acp.jsx
Micha 1b3056f078
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 1s
fix rank creation error
2026-01-18 17:33:44 +01:00

2528 lines
122 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 { 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: (
<span className="bb-sort-label">
{t('user.name')}
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.name,
sortable: true,
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
sensitivity: 'base',
}),
},
{
id: 'email',
name: (
<span className="bb-sort-label">
{t('user.email')}
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.email,
sortable: true,
},
{
id: 'rank',
name: (
<span className="bb-sort-label">
{t('user.rank')}
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
</span>
),
width: '220px',
sortable: true,
sortFunction: (a, b) =>
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
cell: (row) => (
<Form.Select
size="sm"
value={row.rank?.id ?? ''}
disabled={ranksLoading || rankUpdatingId === row.id}
onChange={async (event) => {
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)
}
}}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
),
},
{
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>
{(() => {
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
return (
<Button
variant="dark"
title={editLocked ? t('user.founder_locked') : t('user.edit')}
aria-disabled={editLocked}
className={editLocked ? 'bb-btn-disabled' : undefined}
onClick={() => {
if (editLocked) return
setUserForm({
id: row.id,
name: row.name,
email: row.email,
rankId: row.rank?.id ?? '',
roles: row.roles || [],
})
setShowUserModal(true)
setUsersError('')
}}
>
<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, 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 (
<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])
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) => (
<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 || ''}</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>
<div className="bb-tree-action-group">
<ButtonGroup size="sm" className="bb-action-group w-100">
{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>
</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 fluid 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.forum_name}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forum_name: 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.show_header_name !== 'false'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
show_header_name: event.target.checked ? 'true' : 'false',
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.default_theme}
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.accent_color_dark}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_dark || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: 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.accent_color_light}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_light || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: 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.logo_dark ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_dark} 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.logo_light ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_light} 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.favicon_ico ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_ico} 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.favicon_16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_16} 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.favicon_32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_32} 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.favicon_48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_48} 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.favicon_64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_64} 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.favicon_128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_128} 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.favicon_256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_256} 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>}
{ranksError && <p className="text-danger">{ranksError}</p>}
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!usersLoading && (
<DataTable
columns={userColumns}
data={filteredUsers}
pagination
striped
highlightOnHover={themeMode !== 'dark'}
dense
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
customStyles={userTableStyles}
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
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={
<Form.Control
className="bb-user-search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder={t('user.search')}
/>
}
/>
)}
</Tab>
<Tab eventKey="groups" title={t('acp.groups')}>
{rolesError && <p className="text-danger">{rolesError}</p>}
<div className="d-flex justify-content-end mb-3">
<Button
type="button"
className="bb-accent-button"
onClick={() => setShowRoleCreate(true)}
>
{t('group.create')}
</Button>
</div>
{rolesLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!rolesLoading && roles.length === 0 && (
<p className="bb-muted">{t('group.empty')}</p>
)}
{!rolesLoading && roles.length > 0 && (
<div className="bb-rank-list">
{roles.map((role) => {
const coreRole = isCoreRole(role.name)
return (
<div key={role.id} className="bb-rank-row">
<div className="bb-rank-main">
<span className="d-flex align-items-center gap-2">
{role.color && (
<span
className="bb-rank-color"
style={{ backgroundColor: role.color }}
aria-hidden="true"
/>
)}
<span>{formatRoleLabel(role.name)}</span>
</span>
</div>
<div className="bb-rank-actions">
<Button
size="sm"
variant="dark"
title={coreRole ? t('group.core_locked') : t('group.edit')}
onClick={() => {
setRoleEdit({
id: role.id,
name: formatRoleLabel(role.name),
originalName: role.name,
color: role.color || '',
isCore: coreRole,
})
setShowRoleModal(true)
setRolesError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
disabled={coreRole}
title={coreRole ? t('group.core_locked') : t('group.delete')}
onClick={async () => {
if (coreRole) return
if (!window.confirm(t('group.delete_confirm'))) return
setRoleSaving(true)
setRolesError('')
try {
await deleteRole(role.id)
setRoles((prev) =>
prev.filter((item) => item.id !== role.id)
)
setUsers((prev) =>
prev.map((user) => ({
...user,
roles: (user.roles || []).filter(
(name) => name !== role.name
),
}))
)
} catch (err) {
setRolesError(err.message)
} finally {
setRoleSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
)
})}
</div>
)}
</Tab>
<Tab eventKey="ranks" title={t('acp.ranks')}>
{ranksError && <p className="text-danger">{ranksError}</p>}
<div className="d-flex justify-content-end mb-3">
<Button
type="button"
className="bb-accent-button"
onClick={() => setShowRankCreate(true)}
>
{t('rank.create')}
</Button>
</div>
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!ranksLoading && ranks.length === 0 && (
<p className="bb-muted">{t('rank.empty')}</p>
)}
{!ranksLoading && ranks.length > 0 && (
<div className="bb-rank-list">
{ranks.map((rank) => (
<div key={rank.id} className="bb-rank-row">
<div className="bb-rank-main">
<span className="d-flex align-items-center gap-2">
{rank.color && (
<span
className="bb-rank-color"
style={{ backgroundColor: rank.color }}
aria-hidden="true"
/>
)}
<span>{rank.name}</span>
</span>
</div>
<div className="bb-rank-actions">
{rank.badge_type === 'image' && rank.badge_image_url && (
<img src={rank.badge_image_url} alt="" />
)}
{rank.badge_type !== 'image' && rank.badge_text && (
<span className="bb-rank-badge">{rank.badge_text}</span>
)}
<Button
size="sm"
variant="dark"
onClick={() => {
setRankEdit({
id: rank.id,
name: rank.name,
badgeType: rank.badge_type || 'text',
badgeText: rank.badge_text || '',
badgeImageUrl: rank.badge_image_url || '',
color: rank.color || '',
})
setRankEditImage(null)
setShowRankModal(true)
setRanksError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
onClick={async () => {
if (!window.confirm(t('rank.delete_confirm'))) return
setRankSaving(true)
setRanksError('')
try {
await deleteRank(rank.id)
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)}
</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>
<Modal
show={showUserModal}
onHide={() => setShowUserModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('user.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{usersError && <p className="text-danger">{usersError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
if ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder) {
setUsersError(t('user.founder_locked'))
return
}
setUserSaving(true)
setUsersError('')
try {
const payload = {
name: userForm.name,
email: userForm.email,
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
}
if (roles.length) {
payload.roles = userForm.roles || []
}
const updated = await updateUser(userForm.id, payload)
setUsers((prev) =>
prev.map((user) =>
user.id === updated.id ? { ...user, ...updated } : user
)
)
setShowUserModal(false)
} catch (err) {
setUsersError(err.message)
} finally {
setUserSaving(false)
}
}}
>
{(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder && (
<p className="text-danger">{t('user.founder_locked')}</p>
)}
<Form.Group className="mb-3">
<Form.Label>{t('form.username')}</Form.Label>
<Form.Control
value={userForm.name}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, name: event.target.value }))
}
required
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={userForm.email}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, email: event.target.value }))
}
required
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('user.rank')}</Form.Label>
<Form.Select
value={userForm.rankId ?? ''}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
}
disabled={ranksLoading || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('user.roles')}</Form.Label>
<div className="bb-multiselect" ref={roleMenuRef}>
<button
type="button"
className="bb-multiselect__control"
onClick={() => setRoleMenuOpen((prev) => !prev)}
disabled={!roles.length || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
>
<span className="bb-multiselect__value">
{(userForm.roles || []).length === 0 && (
<span className="bb-multiselect__placeholder">
{t('user.roles')}
</span>
)}
{(userForm.roles || []).map((roleName) => {
const role = roles.find((item) => item.name === roleName)
return (
<span key={roleName} className="bb-multiselect__chip">
{role?.color && (
<span
className="bb-multiselect__chip-color"
style={{ backgroundColor: role.color }}
aria-hidden="true"
/>
)}
{formatRoleLabel(roleName)}
{!(roleName === 'ROLE_FOUNDER' && !canManageFounder) && (
<span
className="bb-multiselect__chip-remove"
onClick={(event) => {
event.stopPropagation()
setUserForm((prev) => ({
...prev,
roles: (prev.roles || []).filter(
(name) => name !== roleName
),
}))
}}
aria-hidden="true"
>
×
</span>
)}
</span>
)
})}
</span>
<span className="bb-multiselect__caret" aria-hidden="true">
<i className="bi bi-chevron-down" />
</span>
</button>
{roleMenuOpen && (
<div className="bb-multiselect__menu">
<div className="bb-multiselect__search">
<input
type="text"
value={roleQuery}
onChange={(event) => setRoleQuery(event.target.value)}
placeholder={t('user.search')}
/>
</div>
<div className="bb-multiselect__options">
{filteredRoles.length === 0 && (
<div className="bb-multiselect__empty">
{t('rank.empty')}
</div>
)}
{filteredRoles.map((role) => {
const isSelected = (userForm.roles || []).includes(role.name)
const isFounderRole = role.name === 'ROLE_FOUNDER'
const isLocked = isFounderRole && !canManageFounder
return (
<button
type="button"
key={role.id}
className={`bb-multiselect__option ${isSelected ? 'is-selected' : ''}`}
onClick={() =>
setUserForm((prev) => {
if (isLocked) {
return prev
}
const next = new Set(prev.roles || [])
if (next.has(role.name)) {
next.delete(role.name)
} else {
next.add(role.name)
}
return { ...prev, roles: Array.from(next) }
})
}
disabled={isLocked}
>
<span className="bb-multiselect__option-main">
{role.color && (
<span
className="bb-multiselect__chip-color"
style={{ backgroundColor: role.color }}
aria-hidden="true"
/>
)}
{formatRoleLabel(role.name)}
</span>
{isSelected && (
<i className="bi bi-check-lg" aria-hidden="true" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowUserModal(false)}
disabled={userSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
{userSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showRoleModal}
onHide={() => setShowRoleModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('group.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{rolesError && <p className="text-danger">{rolesError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
if (!roleEdit.name.trim()) return
setRoleSaving(true)
setRolesError('')
try {
const updated = await updateRole(roleEdit.id, {
name: roleEdit.name.trim(),
color: roleEdit.color.trim() || null,
})
setRoles((prev) =>
prev
.map((item) => (item.id === updated.id ? updated : item))
.sort((a, b) => a.name.localeCompare(b.name))
)
if (roleEdit.originalName && roleEdit.originalName !== updated.name) {
setUsers((prev) =>
prev.map((user) => ({
...user,
roles: (user.roles || []).map((name) =>
name === roleEdit.originalName ? updated.name : name
),
}))
)
}
setShowRoleModal(false)
} catch (err) {
setRolesError(err.message)
} finally {
setRoleSaving(false)
}
}}
>
<Form.Group className="mb-3">
<Form.Label>{t('group.name')}</Form.Label>
<Form.Control
value={roleEdit.name}
onChange={(event) =>
setRoleEdit((prev) => ({ ...prev, name: event.target.value }))
}
disabled={roleEdit.isCore}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('group.color')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
value={roleEdit.color}
onChange={(event) =>
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
}
placeholder={t('group.color_placeholder')}
/>
<Form.Control
type="color"
value={roleEdit.color || '#f29b3f'}
onChange={(event) =>
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
}
/>
</div>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRoleModal(false)}
disabled={roleSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={roleSaving}>
{roleSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showRoleCreate}
onHide={() => setShowRoleCreate(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('group.create_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{rolesError && <p className="text-danger">{rolesError}</p>}
<Form onSubmit={handleCreateRole}>
<Form.Group>
<Form.Label>{t('group.name')}</Form.Label>
<Form.Control
value={roleFormName}
onChange={(event) => setRoleFormName(event.target.value)}
placeholder={t('group.name_placeholder')}
disabled={roleSaving}
/>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('group.color')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
value={roleFormColor}
onChange={(event) => setRoleFormColor(event.target.value)}
placeholder={t('group.color_placeholder')}
disabled={roleSaving}
/>
<Form.Control
type="color"
value={roleFormColor || '#f29b3f'}
onChange={(event) => setRoleFormColor(event.target.value)}
disabled={roleSaving}
/>
</div>
</Form.Group>
<div className="d-flex justify-content-end mt-4">
<Button
type="submit"
className="bb-accent-button"
disabled={roleSaving || !roleFormName.trim()}
>
{roleSaving ? t('form.saving') : t('group.create')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showRankModal}
onHide={() => setShowRankModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('rank.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{ranksError && <p className="text-danger">{ranksError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
if (!rankEdit.name.trim()) return
if (rankEdit.badgeType === 'text' && !rankEdit.badgeText.trim()) {
setRanksError(t('rank.badge_text_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const updated = await updateRank(rankEdit.id, {
name: rankEdit.name.trim(),
badge_type: rankEdit.badgeType,
badge_text:
rankEdit.badgeType === 'text'
? rankEdit.badgeText.trim() || rankEdit.name.trim()
: null,
color: rankEdit.color.trim() || null,
})
let next = updated
if (rankEdit.badgeType === 'image' && rankEditImage) {
const upload = await uploadRankBadgeImage(rankEdit.id, rankEditImage)
next = { ...updated, ...upload }
}
setRanks((prev) =>
prev
.map((item) => (item.id === next.id ? { ...item, ...next } : item))
.sort((a, b) => a.name.localeCompare(b.name))
)
setShowRankModal(false)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<Form.Group className="mb-3">
<Form.Label>{t('rank.name')}</Form.Label>
<Form.Control
value={rankEdit.name}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('rank.color')}</Form.Label>
<div className="d-flex align-items-center gap-3 flex-wrap">
<Form.Check
type="checkbox"
id="rank-edit-color-default"
label={t('rank.color_default')}
checked={!rankEdit.color}
onChange={(event) =>
setRankEdit((prev) => ({
...prev,
color: event.target.checked ? '' : prev.color || '#f29b3f',
}))
}
/>
<div className="d-flex align-items-center gap-2">
<Form.Control
value={rankEdit.color}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
}
placeholder={t('rank.color_placeholder')}
disabled={!rankEdit.color}
/>
<Form.Control
type="color"
value={rankEdit.color || '#f29b3f'}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
}
disabled={!rankEdit.color}
/>
</div>
</div>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_type')}</Form.Label>
<div className="d-flex gap-3 flex-wrap">
<Form.Check
type="radio"
id="rank-edit-badge-none"
name="rankEditBadgeType"
label={t('rank.badge_none')}
checked={rankEdit.badgeType === 'none'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'none' }))
}
/>
<Form.Check
type="radio"
id="rank-edit-badge-text"
name="rankEditBadgeType"
label={t('rank.badge_text')}
checked={rankEdit.badgeType === 'text'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
}
/>
<Form.Check
type="radio"
id="rank-edit-badge-image"
name="rankEditBadgeType"
label={t('rank.badge_image')}
checked={rankEdit.badgeType === 'image'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
}
/>
</div>
</Form.Group>
{rankEdit.badgeType === 'text' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_text')}</Form.Label>
<Form.Control
value={rankEdit.badgeText}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
}
placeholder={t('rank.badge_text_placeholder')}
required
/>
</Form.Group>
)}
{rankEdit.badgeType === 'image' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_image')}</Form.Label>
{rankEdit.badgeImageUrl && !rankEditImage && (
<div className="bb-rank-badge-preview">
<img src={rankEdit.badgeImageUrl} alt="" />
</div>
)}
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
/>
</Form.Group>
)}
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRankModal(false)}
disabled={rankSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
{rankSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showRankCreate}
onHide={() => setShowRankCreate(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('rank.create_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{ranksError && <p className="text-danger">{ranksError}</p>}
<Form
onSubmit={(event) => {
event.preventDefault()
handleCreateRank()
}}
>
<Form.Group>
<Form.Label>{t('rank.name')}</Form.Label>
<Form.Control
value={rankFormName}
onChange={(event) => setRankFormName(event.target.value)}
placeholder={t('rank.name_placeholder')}
disabled={rankSaving}
/>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('rank.color')}</Form.Label>
<div className="d-flex align-items-center gap-3 flex-wrap">
<Form.Check
type="checkbox"
id="rank-create-color-default"
label={t('rank.color_default')}
checked={!rankFormColor}
onChange={(event) =>
setRankFormColor(event.target.checked ? '' : '#f29b3f')
}
disabled={rankSaving}
/>
<div className="d-flex align-items-center gap-2">
<Form.Control
value={rankFormColor}
onChange={(event) => setRankFormColor(event.target.value)}
placeholder={t('rank.color_placeholder')}
disabled={rankSaving || !rankFormColor}
/>
<Form.Control
type="color"
value={rankFormColor || '#f29b3f'}
onChange={(event) => setRankFormColor(event.target.value)}
disabled={rankSaving || !rankFormColor}
/>
</div>
</div>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('rank.badge_type')}</Form.Label>
<div className="d-flex gap-3 flex-wrap">
<Form.Check
type="radio"
id="rank-badge-none"
name="rankBadgeType"
label={t('rank.badge_none')}
checked={rankFormType === 'none'}
onChange={() => setRankFormType('none')}
/>
<Form.Check
type="radio"
id="rank-badge-text"
name="rankBadgeType"
label={t('rank.badge_text')}
checked={rankFormType === 'text'}
onChange={() => setRankFormType('text')}
/>
<Form.Check
type="radio"
id="rank-badge-image"
name="rankBadgeType"
label={t('rank.badge_image')}
checked={rankFormType === 'image'}
onChange={() => setRankFormType('image')}
/>
</div>
</Form.Group>
{rankFormType === 'text' && (
<Form.Group className="mt-3">
<Form.Label>{t('rank.badge_text')}</Form.Label>
<Form.Control
value={rankFormText}
onChange={(event) => setRankFormText(event.target.value)}
placeholder={t('rank.badge_text_placeholder')}
disabled={rankSaving}
/>
</Form.Group>
)}
{rankFormType === 'image' && (
<Form.Group className="mt-3">
<Form.Label>{t('rank.badge_image')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
disabled={rankSaving}
/>
</Form.Group>
)}
<div className="d-flex justify-content-end mt-4">
<Button
type="submit"
className="bb-accent-button"
disabled={rankSaving || !rankFormName.trim()}
>
{rankSaving ? t('form.saving') : t('rank.create')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
</Container>
)
}