Files
speedBB/resources/js/pages/Acp.jsx
tracer 9c60a8944e
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
feat: system tools and admin enhancements
2026-01-31 20:12:09 +01:00

4743 lines
235 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, useId } from 'react'
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Tooltip } 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,
fetchStats,
fetchVersionCheck,
runSystemUpdate,
fetchSystemStatus,
listAllForums,
listRanks,
listRoles,
listUsers,
listAuditLogs,
reorderForums,
saveSetting,
saveSettings,
createRank,
deleteRank,
updateUserRank,
updateRank,
updateUser,
createRole,
updateRole,
deleteRole,
uploadRankBadgeImage,
uploadFavicon,
uploadLogo,
updateForum,
listAttachmentGroups,
createAttachmentGroup,
updateAttachmentGroup,
deleteAttachmentGroup,
reorderAttachmentGroups,
listAttachmentExtensions,
createAttachmentExtension,
updateAttachmentExtension,
deleteAttachmentExtension,
} from '../api/client'
const StatusIcon = ({ status = 'bad', tooltip }) => {
const id = useId()
const iconClass =
status === 'ok' ? 'bi-check-circle-fill' : status === 'warn' ? 'bi-question-circle-fill' : 'bi-x-circle-fill'
const content = (
<span className={`bb-status-icon is-${status}`}>
<i className={`bi ${iconClass}`} aria-hidden="true" />
</span>
)
if (!tooltip) {
return content
}
return (
<OverlayTrigger placement="top" overlay={<Tooltip id={id}>{tooltip}</Tooltip>}>
<span>{content}</span>
</OverlayTrigger>
)
}
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 [auditLogs, setAuditLogs] = useState([])
const [auditSearch, setAuditSearch] = useState('')
const [auditLoading, setAuditLoading] = useState(false)
const [auditError, setAuditError] = useState('')
const [auditLimit, setAuditLimit] = useState(200)
const [boardStats, setBoardStats] = useState(null)
const [boardStatsLoading, setBoardStatsLoading] = useState(false)
const [boardStatsError, setBoardStatsError] = useState('')
const [versionCheck, setVersionCheck] = useState(null)
const [versionChecking, setVersionChecking] = useState(false)
const [versionCheckError, setVersionCheckError] = useState('')
const [updateModalOpen, setUpdateModalOpen] = useState(false)
const [updateLog, setUpdateLog] = useState([])
const [updateRunning, setUpdateRunning] = useState(false)
const [updateError, setUpdateError] = useState('')
const [systemStatus, setSystemStatus] = useState(null)
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = 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 [attachmentGroups, setAttachmentGroups] = useState([])
const [attachmentGroupsLoading, setAttachmentGroupsLoading] = useState(false)
const [attachmentGroupsError, setAttachmentGroupsError] = useState('')
const [attachmentGroupSaving, setAttachmentGroupSaving] = useState(false)
const [showAttachmentGroupModal, setShowAttachmentGroupModal] = useState(false)
const [attachmentGroupEdit, setAttachmentGroupEdit] = useState({
id: null,
name: '',
parentId: '',
max_size_kb: 25600,
is_active: true,
})
const [attachmentExtensions, setAttachmentExtensions] = useState([])
const [attachmentExtensionsLoading, setAttachmentExtensionsLoading] = useState(false)
const [attachmentExtensionsError, setAttachmentExtensionsError] = useState('')
const [attachmentExtensionSaving, setAttachmentExtensionSaving] = useState(false)
const [attachmentExtensionSavingId, setAttachmentExtensionSavingId] = useState(null)
const [attachmentSeedSaving, setAttachmentSeedSaving] = useState(false)
const [attachmentSettingsSaving, setAttachmentSettingsSaving] = useState(false)
const [attachmentSettings, setAttachmentSettings] = useState({
display_images_inline: 'true',
create_thumbnails: 'true',
thumbnail_max_width: '300',
thumbnail_max_height: '300',
thumbnail_quality: '85',
})
const [showAttachmentExtensionModal, setShowAttachmentExtensionModal] = useState(false)
const [attachmentExtensionEdit, setAttachmentExtensionEdit] = useState(null)
const [showAttachmentExtensionDelete, setShowAttachmentExtensionDelete] = useState(false)
const [attachmentExtensionDeleteTarget, setAttachmentExtensionDeleteTarget] = useState(null)
const [newAttachmentExtension, setNewAttachmentExtension] = useState({
extension: '',
groupId: '',
allowedMimes: '',
})
const attachmentExtensionInputRef = useRef(null)
const [attachmentGroupCollapsed, setAttachmentGroupCollapsed] = useState(new Set())
const [attachmentGroupDraggingId, setAttachmentGroupDraggingId] = useState(null)
const [attachmentGroupOverId, setAttachmentGroupOverId] = useState(null)
const attachmentGroupPendingOrder = useRef(null)
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)
setAttachmentSettings({
display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true',
create_thumbnails: settingsMap.get('attachments.create_thumbnails') || 'true',
thumbnail_max_width: settingsMap.get('attachments.thumbnail_max_width') || '300',
thumbnail_max_height: settingsMap.get('attachments.thumbnail_max_height') || '300',
thumbnail_quality: settingsMap.get('attachments.thumbnail_quality') || '85',
})
} 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 handleAttachmentSettingsSave = async (event) => {
event.preventDefault()
setAttachmentSettingsSaving(true)
setAttachmentGroupsError('')
try {
await saveSettings(
Object.entries(attachmentSettings).map(([key, value]) => ({
key: `attachments.${key}`,
value: typeof value === 'string' ? value.trim() : String(value ?? ''),
}))
)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentSettingsSaving(false)
}
}
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 formatAuditAction = (action) => {
if (!action) return ''
return action
.replace(/[._]/g, ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
const formatAuditSubject = (entry) => {
if (!entry) return '-'
const meta = entry.metadata || {}
if (meta.title) return meta.title
if (meta.original_name) return meta.original_name
if (meta.name) return meta.name
if (entry.subject_type) {
const base = entry.subject_type.split('\\').pop()
return entry.subject_id ? `${base} #${entry.subject_id}` : base
}
return '-'
}
const filteredAuditLogs = useMemo(() => {
const term = auditSearch.trim().toLowerCase()
if (!term) return auditLogs
return auditLogs.filter((entry) => {
const metaValues = []
if (entry.metadata && typeof entry.metadata === 'object') {
Object.values(entry.metadata).forEach((value) => {
if (value !== null && value !== undefined) {
metaValues.push(String(value))
}
})
}
const haystack = [
formatAuditAction(entry.action),
formatAuditSubject(entry),
entry.user?.name || '',
entry.user?.email || '',
entry.ip_address || '',
...metaValues,
]
return haystack.some((value) => value.toLowerCase().includes(term))
})
}, [auditLogs, auditSearch])
const adminAuditLogs = useMemo(() => {
return auditLogs.filter((entry) =>
Array.isArray(entry.user?.roles) && entry.user.roles.includes('ROLE_ADMIN')
)
}, [auditLogs])
const recentAdminLogs = useMemo(() => adminAuditLogs.slice(0, 5), [adminAuditLogs])
const formatNumber = (value) => {
if (value === null || value === undefined) return '—'
return new Intl.NumberFormat().format(value)
}
const formatDecimal = (value) => {
if (value === null || value === undefined) return '—'
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)
}
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return '—'
if (bytes === 0) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
const value = bytes / 1024 ** idx
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[idx]}`
}
const formatDateTime = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleString()
}
const formatBool = (value) => {
if (value === null || value === undefined) return '—'
return value ? t('stats.on') : t('stats.off')
}
const handleVersionCheck = async () => {
setVersionChecking(true)
setVersionCheckError('')
try {
const data = await fetchVersionCheck()
setVersionCheck(data)
} catch (err) {
setVersionCheckError(err.message)
} finally {
setVersionChecking(false)
}
}
const handleRunUpdate = async () => {
setUpdateRunning(true)
setUpdateError('')
setUpdateLog([])
try {
const data = await runSystemUpdate()
setUpdateLog(data.log || [])
} catch (err) {
setUpdateError(err.message)
} finally {
setUpdateRunning(false)
handleVersionCheck()
}
}
const loadSystemStatus = async () => {
setSystemLoading(true)
setSystemError('')
try {
const data = await fetchSystemStatus()
setSystemStatus(data)
} catch (err) {
setSystemError(err.message)
} finally {
setSystemLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
handleVersionCheck()
}
}, [isAdmin])
useEffect(() => {
if (isAdmin) {
loadSystemStatus()
}
}, [isAdmin])
const statsLeft = useMemo(() => {
const versionMeta = (() => {
if (versionChecking) return t('version.checking')
if (versionCheckError) return t('version.unknown')
if (!versionCheck) return t('version.unknown')
if (versionCheck.is_latest === true) return t('version.up_to_date')
if (versionCheck.is_latest === false) {
return versionCheck.latest_version
? t('version.update_available', { version: versionCheck.latest_version })
: t('version.update_available_short')
}
return t('version.unknown')
})()
const showUpdate = versionCheck?.is_latest === false
return [
{ label: t('stats.board_started'), value: formatDateTime(boardStats?.board_started_at) },
{ label: t('stats.avatar_directory_size'), value: formatBytes(boardStats?.avatar_directory_size_bytes) },
{ label: t('stats.database_size'), value: formatBytes(boardStats?.database_size_bytes) },
{ label: t('stats.attachments_size'), value: formatBytes(boardStats?.attachments_size_bytes) },
{ label: t('stats.database_server'), value: boardStats?.database_server || '—' },
{ label: t('stats.gzip_compression'), value: formatBool(boardStats?.gzip_compression) },
{ label: t('stats.php_version'), value: boardStats?.php_version || '—' },
{ label: t('stats.orphan_attachments'), value: formatNumber(boardStats?.orphan_attachments) },
{
label: t('stats.board_version'),
value: (
<div className="bb-acp-version-inline">
<span>{boardStats?.board_version || '—'}</span>
<button
type="button"
className="btn btn-link p-0 bb-acp-version-link"
onClick={handleVersionCheck}
disabled={versionChecking}
>
{t('version.recheck')}
</button>
{showUpdate && (
<button
type="button"
className="btn btn-link p-0 bb-acp-version-link"
onClick={() => setUpdateModalOpen(true)}
disabled={updateRunning}
>
{t('version.update_now')}
</button>
)}
<span className="bb-acp-version-meta">{versionMeta}</span>
</div>
),
},
]
}, [t, boardStats, formatBool, versionCheck, versionChecking, versionCheckError, updateRunning])
const statsRight = useMemo(() => {
return [
{ label: t('stats.posts'), value: formatNumber(boardStats?.posts) },
{ label: t('stats.posts_per_day'), value: formatDecimal(boardStats?.posts_per_day) },
{ label: t('stats.topics'), value: formatNumber(boardStats?.threads) },
{ label: t('stats.topics_per_day'), value: formatDecimal(boardStats?.topics_per_day) },
{ label: t('stats.users'), value: formatNumber(boardStats?.users) },
{ label: t('stats.users_per_day'), value: formatDecimal(boardStats?.users_per_day) },
{ label: t('stats.attachments'), value: formatNumber(boardStats?.attachments) },
{ label: t('stats.attachments_per_day'), value: formatDecimal(boardStats?.attachments_per_day) },
]
}, [t, boardStats])
const auditColumns = useMemo(
() => [
{
name: t('audit.created_at'),
selector: (row) => row.created_at,
sortable: true,
width: '190px',
cell: (row) =>
row.created_at ? new Date(row.created_at).toLocaleString() : '-',
},
{
name: t('audit.user'),
selector: (row) => row.user?.name || '',
sortable: true,
cell: (row) => row.user?.name || row.user?.email || '-',
},
{
name: t('audit.action'),
selector: (row) => row.action || '',
sortable: true,
cell: (row) => formatAuditAction(row.action),
},
{
name: t('audit.subject'),
selector: (row) => formatAuditSubject(row),
sortable: true,
grow: 2,
cell: (row) => formatAuditSubject(row),
},
{
name: t('audit.ip'),
selector: (row) => row.ip_address || '',
sortable: true,
width: '160px',
cell: (row) => row.ip_address || '-',
},
],
[t]
)
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
const current = Math.min(usersPage, totalPages)
const pages = []
for (let page = Math.max(1, current - 2); page <= Math.min(totalPages, current + 2); page += 1) {
pages.push(page)
}
const goTo = (page) => {
if (page < 1 || page > totalPages) return
onChangePage(page, rowCount)
}
return (
<div className="bb-pagination">
<div className="bb-pagination-range">
{t('table.rows_per_page')} {rowsPerPage}{' '}
<span className="bb-muted">
{((current - 1) * rowsPerPage) + 1}-{Math.min(current * rowsPerPage, rowCount)} {t('table.range_separator')} {rowCount}
</span>
</div>
<div className="bb-pagination-actions">
<button type="button" onClick={() => goTo(1)} disabled={current === 1}>
«
</button>
<button type="button" onClick={() => goTo(current - 1)} disabled={current === 1}>
</button>
{pages.map((page) => (
<button
key={page}
type="button"
className={page === current ? 'is-active' : ''}
onClick={() => goTo(page)}
>
{page}
</button>
))}
<button type="button" onClick={() => goTo(current + 1)} disabled={current === totalPages}>
</button>
<button type="button" onClick={() => goTo(totalPages)} disabled={current === totalPages}>
»
</button>
</div>
</div>
)
}
const [collapsed, setCollapsed] = useState(() => new Set())
const hasInitializedCollapse = useRef(false)
const [showModal, setShowModal] = useState(false)
const [form, setForm] = useState({
name: '',
description: '',
type: 'category',
parentId: '',
})
const refreshForums = async () => {
setLoading(true)
setError('')
try {
const data = await listAllForums()
setForums(data.filter((forum) => !forum.deleted_at))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshForums()
}
}, [isAdmin])
useEffect(() => {
if (!hasInitializedCollapse.current && forums.length > 0) {
const ids = forums
.filter((forum) => forum.type === 'category')
.map((forum) => String(forum.id))
setCollapsed(new Set(ids))
hasInitializedCollapse.current = true
}
}, [forums])
const refreshUsers = async () => {
setUsersLoading(true)
setUsersError('')
try {
const data = await listUsers()
setUsers(data)
} catch (err) {
setUsersError(err.message)
} finally {
setUsersLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshUsers()
}
}, [isAdmin])
const refreshBoardStats = async () => {
setBoardStatsLoading(true)
setBoardStatsError('')
try {
const data = await fetchStats()
setBoardStats(data)
} catch (err) {
setBoardStatsError(err.message)
} finally {
setBoardStatsLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshBoardStats()
}
}, [isAdmin])
const refreshAuditLogs = async () => {
setAuditLoading(true)
setAuditError('')
try {
const data = await listAuditLogs(auditLimit)
setAuditLogs(data)
} catch (err) {
setAuditError(err.message)
} finally {
setAuditLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshAuditLogs()
}
}, [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)
setShowRankCreate(false)
} 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 formatSizeKb = (kb) => {
if (!kb && kb !== 0) return ''
if (kb < 1024) return `${kb} KB`
return `${(kb / 1024).toFixed(1)} MB`
}
const attachmentDefaultSeed = [
{
name: 'Media',
max_size_kb: 25600,
children: [
{
name: 'Image',
max_size_kb: 25600,
extensions: [
{ ext: 'png', mimes: ['image/png'] },
{ ext: 'jpg', mimes: ['image/jpeg'] },
{ ext: 'jpeg', mimes: ['image/jpeg'] },
{ ext: 'gif', mimes: ['image/gif'] },
{ ext: 'webp', mimes: ['image/webp'] },
{ ext: 'bmp', mimes: ['image/bmp'] },
{ ext: 'svg', mimes: ['image/svg+xml'] },
],
},
{
name: 'Video',
max_size_kb: 102400,
extensions: [
{ ext: 'mp4', mimes: ['video/mp4'] },
{ ext: 'webm', mimes: ['video/webm'] },
{ ext: 'mov', mimes: ['video/quicktime'] },
{ ext: 'avi', mimes: ['video/x-msvideo'] },
{ ext: 'mkv', mimes: ['video/x-matroska'] },
],
},
{
name: 'Audio',
max_size_kb: 25600,
extensions: [
{ ext: 'mp3', mimes: ['audio/mpeg'] },
{ ext: 'wav', mimes: ['audio/wav'] },
{ ext: 'ogg', mimes: ['audio/ogg'] },
{ ext: 'flac', mimes: ['audio/flac'] },
{ ext: 'm4a', mimes: ['audio/mp4'] },
],
},
],
},
{
name: 'Files',
max_size_kb: 25600,
children: [
{
name: 'Archive',
max_size_kb: 51200,
extensions: [
{ ext: 'zip', mimes: ['application/zip'] },
{ ext: 'rar', mimes: ['application/vnd.rar'] },
{ ext: '7z', mimes: ['application/x-7z-compressed'] },
{ ext: 'tar', mimes: ['application/x-tar'] },
{ ext: 'gz', mimes: ['application/gzip'] },
],
},
{
name: 'Documents',
max_size_kb: 25600,
extensions: [
{ ext: 'txt', mimes: ['text/plain'] },
{ ext: 'pdf', mimes: ['application/pdf'] },
{ ext: 'doc', mimes: ['application/msword'] },
{ ext: 'docx', mimes: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'] },
{ ext: 'rtf', mimes: ['application/rtf'] },
{ ext: 'odt', mimes: ['application/vnd.oasis.opendocument.text'] },
{ ext: 'xls', mimes: ['application/vnd.ms-excel'] },
{ ext: 'xlsx', mimes: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] },
{ ext: 'ppt', mimes: ['application/vnd.ms-powerpoint'] },
{ ext: 'pptx', mimes: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'] },
{ ext: 'csv', mimes: ['text/csv'] },
],
},
{
name: 'Other',
max_size_kb: 25600,
extensions: [
{ ext: 'json', mimes: ['application/json'] },
{ ext: 'xml', mimes: ['application/xml', 'text/xml'] },
{ ext: 'log', mimes: ['text/plain'] },
],
},
],
},
]
const normalizeList = (value) =>
value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean)
const loadAttachmentGroups = async () => {
setAttachmentGroupsLoading(true)
setAttachmentGroupsError('')
try {
const data = await listAttachmentGroups()
setAttachmentGroups(data)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentGroupsLoading(false)
}
}
const loadAttachmentExtensions = async () => {
setAttachmentExtensionsLoading(true)
setAttachmentExtensionsError('')
try {
const data = await listAttachmentExtensions()
setAttachmentExtensions(data)
} catch (err) {
setAttachmentExtensionsError(err.message)
} finally {
setAttachmentExtensionsLoading(false)
}
}
const openAttachmentGroupModal = (group = null) => {
if (group) {
setAttachmentGroupEdit({
id: group.id,
name: group.name || '',
parentId: group.parent_id ? String(group.parent_id) : '',
max_size_kb: group.max_size_kb ?? 25600,
is_active: group.is_active ?? true,
})
} else {
setAttachmentGroupEdit({
id: null,
name: '',
parentId: '',
max_size_kb: 25600,
is_active: true,
})
}
setShowAttachmentGroupModal(true)
}
const openAttachmentGroupChildModal = (parentId) => {
setAttachmentGroupEdit({
id: null,
name: '',
parentId: parentId ? String(parentId) : '',
max_size_kb: 25600,
is_active: true,
})
setShowAttachmentGroupModal(true)
}
const handleAttachmentGroupSubmit = async (event) => {
event.preventDefault()
setAttachmentGroupSaving(true)
setAttachmentGroupsError('')
try {
const payload = {
name: attachmentGroupEdit.name.trim(),
parent_id: attachmentGroupEdit.parentId ? Number(attachmentGroupEdit.parentId) : null,
max_size_kb: Number(attachmentGroupEdit.max_size_kb) || 1,
is_active: Boolean(attachmentGroupEdit.is_active),
}
if (attachmentGroupEdit.id) {
const updated = await updateAttachmentGroup(attachmentGroupEdit.id, payload)
setAttachmentGroups((prev) =>
prev.map((item) => (item.id === updated.id ? updated : item))
)
} else {
const created = await createAttachmentGroup(payload)
setAttachmentGroups((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
}
setShowAttachmentGroupModal(false)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentGroupSaving(false)
}
}
const handleAttachmentGroupDelete = async (group) => {
if (!window.confirm(t('attachment.group_delete_confirm'))) return
setAttachmentGroupSaving(true)
setAttachmentGroupsError('')
try {
await deleteAttachmentGroup(group.id)
setAttachmentGroups((prev) => prev.filter((item) => item.id !== group.id))
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentGroupSaving(false)
}
}
const handleAttachmentExtensionCreate = async (event) => {
event.preventDefault()
if (!newAttachmentExtension.extension.trim()) return
setAttachmentExtensionSaving(true)
setAttachmentExtensionsError('')
try {
if (attachmentExtensionEdit) {
const payload = {
attachment_group_id: newAttachmentExtension.groupId
? Number(newAttachmentExtension.groupId)
: null,
allowed_mimes: newAttachmentExtension.allowedMimes
? normalizeList(newAttachmentExtension.allowedMimes)
: null,
}
const updated = await updateAttachmentExtension(attachmentExtensionEdit.id, payload)
setAttachmentExtensions((prev) =>
prev.map((item) => (item.id === updated.id ? updated : item))
)
} else {
const payload = {
extension: newAttachmentExtension.extension.trim(),
attachment_group_id: newAttachmentExtension.groupId
? Number(newAttachmentExtension.groupId)
: null,
allowed_mimes: newAttachmentExtension.allowedMimes
? normalizeList(newAttachmentExtension.allowedMimes)
: null,
}
const created = await createAttachmentExtension(payload)
setAttachmentExtensions((prev) =>
[...prev, created].sort((a, b) => a.extension.localeCompare(b.extension))
)
}
setNewAttachmentExtension({ extension: '', groupId: '', allowedMimes: '' })
setAttachmentExtensionEdit(null)
setShowAttachmentExtensionModal(false)
} catch (err) {
setAttachmentExtensionsError(err.message)
} finally {
setAttachmentExtensionSaving(false)
}
}
const handleAttachmentExtensionUpdate = async (extensionId, groupId, allowedMimes) => {
setAttachmentExtensionSavingId(extensionId)
setAttachmentExtensionsError('')
try {
const payload = {
attachment_group_id: groupId ? Number(groupId) : null,
allowed_mimes: allowedMimes ? normalizeList(allowedMimes) : null,
}
const updated = await updateAttachmentExtension(extensionId, payload)
setAttachmentExtensions((prev) =>
prev.map((item) => (item.id === updated.id ? updated : item))
)
} catch (err) {
setAttachmentExtensionsError(err.message)
} finally {
setAttachmentExtensionSavingId(null)
}
}
const handleAttachmentExtensionDelete = async (extension) => {
setAttachmentExtensionDeleteTarget(extension)
setShowAttachmentExtensionDelete(true)
}
const confirmAttachmentExtensionDelete = async () => {
if (!attachmentExtensionDeleteTarget) return
setAttachmentExtensionSaving(true)
setAttachmentExtensionsError('')
try {
await deleteAttachmentExtension(attachmentExtensionDeleteTarget.id)
setAttachmentExtensions((prev) =>
prev.filter((item) => item.id !== attachmentExtensionDeleteTarget.id)
)
setShowAttachmentExtensionDelete(false)
setAttachmentExtensionDeleteTarget(null)
} catch (err) {
setAttachmentExtensionsError(err.message)
} finally {
setAttachmentExtensionSaving(false)
}
}
const openAttachmentExtensionEdit = (extension) => {
setAttachmentExtensionEdit(extension)
setNewAttachmentExtension({
extension: extension.extension || '',
groupId: extension.attachment_group_id ? String(extension.attachment_group_id) : '',
allowedMimes: (extension.allowed_mimes || []).join(', '),
})
setShowAttachmentExtensionModal(true)
}
const handleSeedAttachmentDefaults = async () => {
setAttachmentSeedSaving(true)
setAttachmentGroupsError('')
setAttachmentExtensionsError('')
try {
const groupsByName = new Map(attachmentGroups.map((group) => [group.name, group]))
const extensionsByGroup = new Map()
attachmentExtensions.forEach((ext) => {
const groupId = ext.attachment_group_id ? String(ext.attachment_group_id) : 'none'
if (!extensionsByGroup.has(groupId)) {
extensionsByGroup.set(groupId, new Set())
}
extensionsByGroup
.get(groupId)
.add(String(ext.extension || '').toLowerCase())
})
const createdGroups = []
const createdExtensions = []
for (const group of attachmentDefaultSeed) {
let parent = groupsByName.get(group.name)
if (!parent) {
parent = await createAttachmentGroup({
name: group.name,
max_size_kb: group.max_size_kb,
is_active: true,
})
groupsByName.set(group.name, parent)
createdGroups.push(parent)
}
createdGroups.push(parent)
for (const child of group.children || []) {
let createdChild = groupsByName.get(child.name)
if (!createdChild) {
createdChild = await createAttachmentGroup({
name: child.name,
parent_id: parent.id,
max_size_kb: child.max_size_kb,
is_active: true,
})
groupsByName.set(child.name, createdChild)
createdGroups.push(createdChild)
}
const groupKey = String(createdChild.id)
const existingSet = extensionsByGroup.get(groupKey) || new Set()
for (const ext of child.extensions || []) {
const key = String(ext.ext || '').toLowerCase()
if (existingSet.has(key)) {
continue
}
const createdExt = await createAttachmentExtension({
extension: ext.ext,
attachment_group_id: createdChild.id,
allowed_mimes: ext.mimes,
})
createdExtensions.push(createdExt)
existingSet.add(key)
}
extensionsByGroup.set(groupKey, existingSet)
}
}
setAttachmentGroups((prev) =>
[...prev, ...createdGroups].sort((a, b) => a.name.localeCompare(b.name))
)
setAttachmentExtensions((prev) =>
[...prev, ...createdExtensions].sort((a, b) => a.extension.localeCompare(b.extension))
)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentSeedSaving(false)
}
}
const handleAttachmentGroupAutoNest = async () => {
const groupsByName = new Map(attachmentGroups.map((group) => [group.name, group]))
const needsParent = attachmentGroups.every((group) => !group.parent_id)
if (!needsParent) return
setAttachmentSeedSaving(true)
setAttachmentGroupsError('')
try {
const ensureParent = async (name) => {
const existing = groupsByName.get(name)
if (existing) return existing
const created = await createAttachmentGroup({
name,
max_size_kb: 25600,
is_active: true,
})
groupsByName.set(name, created)
setAttachmentGroups((prev) => [...prev, created])
return created
}
const mediaParent = await ensureParent('Media')
const filesParent = await ensureParent('Files')
const mapping = {
Image: mediaParent.id,
Video: mediaParent.id,
Audio: mediaParent.id,
Archive: filesParent.id,
Documents: filesParent.id,
Other: filesParent.id,
}
const updatedGroups = []
for (const group of attachmentGroups) {
const targetParent = mapping[group.name]
if (!targetParent) continue
const updated = await updateAttachmentGroup(group.id, {
name: group.name,
parent_id: targetParent,
max_size_kb: group.max_size_kb,
is_active: group.is_active,
})
updatedGroups.push(updated)
}
setAttachmentGroups((prev) =>
prev.map((group) => updatedGroups.find((u) => u.id === group.id) || group)
)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentSeedSaving(false)
}
}
const handleAddExtensionForGroup = (groupId) => {
setNewAttachmentExtension({
extension: '',
groupId: groupId ? String(groupId) : '',
allowedMimes: '',
})
setAttachmentExtensionEdit(null)
setShowAttachmentExtensionModal(true)
}
const openAttachmentExtensionModal = () => {
setNewAttachmentExtension({
extension: '',
groupId: '',
allowedMimes: '',
})
setAttachmentExtensionEdit(null)
setShowAttachmentExtensionModal(true)
}
useEffect(() => {
if (isAdmin) {
loadAttachmentGroups()
loadAttachmentExtensions()
}
}, [isAdmin])
useEffect(() => {
if (attachmentGroups.length === 0) return
const ids = attachmentGroups.map((group) => String(group.id))
setAttachmentGroupCollapsed(new Set(ids))
}, [attachmentGroups.length])
const getAttachmentGroupParentId = (group) => group.parent_id ?? null
const buildAttachmentGroupTree = useMemo(() => {
const map = new Map()
const roots = []
attachmentGroups.forEach((group) => {
map.set(String(group.id), { ...group, children: [] })
})
attachmentGroups.forEach((group) => {
const parentId = group.parent_id
const node = map.get(String(group.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) => {
const aPos = a.position ?? 0
const bPos = b.position ?? 0
if (aPos !== bPos) return aPos - bPos
return (a.name || '').localeCompare(b.name || '')
})
nodes.forEach((node) => {
if (node.children?.length) {
sortNodes(node.children)
}
})
}
sortNodes(roots)
return roots
}, [attachmentGroups])
const attachmentGroupOptions = useMemo(() => {
const options = []
const walk = (nodes, depth) => {
nodes.forEach((node) => {
const prefix = depth > 0 ? `${'—'.repeat(depth)} ` : ''
options.push({ id: node.id, label: `${prefix}${node.name}` })
if (node.children?.length) {
walk(node.children, depth + 1)
}
})
}
walk(buildAttachmentGroupTree, 0)
return options
}, [buildAttachmentGroupTree])
const isAttachmentGroupExpanded = (groupId) => !attachmentGroupCollapsed.has(String(groupId))
const toggleAttachmentGroupExpanded = (groupId) => {
const key = String(groupId)
setAttachmentGroupCollapsed((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const handleAttachmentGroupCollapseAll = () => {
const ids = attachmentGroups.map((group) => String(group.id))
setAttachmentGroupCollapsed(new Set(ids))
}
const handleAttachmentGroupExpandAll = () => {
setAttachmentGroupCollapsed(new Set())
}
const applyAttachmentGroupLocalOrder = (parentId, orderedIds) => {
setAttachmentGroups((prev) =>
prev.map((group) => {
const pid = getAttachmentGroupParentId(group)
if (String(pid ?? '') !== String(parentId ?? '')) {
return group
}
const newIndex = orderedIds.indexOf(String(group.id))
return newIndex === -1 ? group : { ...group, position: newIndex + 1 }
})
)
}
const handleAttachmentGroupDragStart = (event, groupId) => {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(groupId))
setAttachmentGroupDraggingId(String(groupId))
}
const handleAttachmentGroupDragEnd = () => {
if (attachmentGroupPendingOrder.current) {
const { parentId, ordered } = attachmentGroupPendingOrder.current
attachmentGroupPendingOrder.current = null
reorderAttachmentGroups(parentId, ordered).catch((err) => setAttachmentGroupsError(err.message))
}
setAttachmentGroupDraggingId(null)
setAttachmentGroupOverId(null)
}
const handleAttachmentGroupDragOver = (event, targetId, parentId) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
if (!attachmentGroupDraggingId || String(attachmentGroupDraggingId) === String(targetId)) {
return
}
const draggedGroup = attachmentGroups.find(
(group) => String(group.id) === String(attachmentGroupDraggingId)
)
if (!draggedGroup) {
return
}
const draggedParentId = getAttachmentGroupParentId(draggedGroup)
if (String(draggedParentId ?? '') !== String(parentId ?? '')) {
return
}
const siblings = attachmentGroups.filter((group) => {
const pid = getAttachmentGroupParentId(group)
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((group) => String(group.id))
const fromIndex = ordered.indexOf(String(attachmentGroupDraggingId))
const toIndex = ordered.indexOf(String(targetId))
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return
}
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
setAttachmentGroupOverId(String(targetId))
applyAttachmentGroupLocalOrder(parentId, ordered)
attachmentGroupPendingOrder.current = { parentId, ordered }
}
const handleAttachmentGroupDragEnter = (groupId) => {
if (attachmentGroupDraggingId && String(groupId) !== String(attachmentGroupDraggingId)) {
setAttachmentGroupOverId(String(groupId))
}
}
const handleAttachmentGroupDragLeave = (event, groupId) => {
if (event.currentTarget.contains(event.relatedTarget)) {
return
}
if (attachmentGroupOverId === String(groupId)) {
setAttachmentGroupOverId(null)
}
}
const handleAttachmentGroupDrop = async (event, targetId, parentId) => {
event.preventDefault()
const draggedId = event.dataTransfer.getData('text/plain')
if (!draggedId || String(draggedId) === String(targetId)) {
setAttachmentGroupDraggingId(null)
setAttachmentGroupOverId(null)
return
}
const siblings = attachmentGroups.filter((group) => {
const pid = getAttachmentGroupParentId(group)
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((group) => String(group.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])
attachmentGroupPendingOrder.current = null
try {
await reorderAttachmentGroups(parentId, ordered)
const updated = attachmentGroups.map((group) => {
const pid = getAttachmentGroupParentId(group)
if (String(pid ?? '') !== String(parentId ?? '')) {
return group
}
const newIndex = ordered.indexOf(String(group.id))
return newIndex === -1 ? group : { ...group, position: newIndex + 1 }
})
setAttachmentGroups(updated)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentGroupDraggingId(null)
setAttachmentGroupOverId(null)
}
}
const renderAttachmentGroupTree = (nodes, depth = 0) =>
nodes.map((node) => {
const groupExtensions = attachmentExtensions.filter(
(ext) => Number(ext.attachment_group_id) === Number(node.id)
)
const isExpandable = (node.children?.length || 0) > 0 || groupExtensions.length > 0
return (
<div key={node.id}>
<div
className={`bb-drag-item d-flex align-items-center justify-content-between border rounded p-2 mb-2 ${
attachmentGroupOverId === String(node.id) ? 'bb-drop-target' : ''
} ${attachmentGroupDraggingId === String(node.id) ? 'bb-dragging' : ''}`}
style={{ marginLeft: depth * 16 }}
draggable
onDragStart={(event) => handleAttachmentGroupDragStart(event, node.id)}
onDragEnd={handleAttachmentGroupDragEnd}
onDragOver={(event) =>
handleAttachmentGroupDragOver(event, node.id, getAttachmentGroupParentId(node))
}
onDragEnter={() => handleAttachmentGroupDragEnter(node.id)}
onDragLeave={(event) => handleAttachmentGroupDragLeave(event, node.id)}
onDrop={(event) => handleAttachmentGroupDrop(event, node.id, getAttachmentGroupParentId(node))}
>
<div
className="d-flex align-items-start gap-3 bb-attachment-tree-toggle"
role={isExpandable ? 'button' : undefined}
tabIndex={isExpandable ? 0 : undefined}
onClick={() => {
if (isExpandable) {
toggleAttachmentGroupExpanded(node.id)
}
}}
onKeyDown={(event) => {
if (isExpandable && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
toggleAttachmentGroupExpanded(node.id)
}
}}
>
<span className="bb-icon">
<i className="bi bi-folder2" />
{isExpandable && (
<button
type="button"
className="bb-collapse-toggle"
onClick={(event) => {
event.stopPropagation()
toggleAttachmentGroupExpanded(node.id)
}}
aria-label={
isAttachmentGroupExpanded(node.id) ? t('acp.collapse') : t('acp.expand')
}
title={
isAttachmentGroupExpanded(node.id) ? t('acp.collapse') : t('acp.expand')
}
>
<i
className={`bi ${
isAttachmentGroupExpanded(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 d-flex flex-wrap gap-2">
<span>{formatSizeKb(node.max_size_kb)}</span>
<span></span>
<span>
{node.is_active ? t('attachment.active') : t('attachment.inactive')}
</span>
{typeof node.extensions_count === 'number' && (
<>
<span></span>
<span>
{t('attachment.group_extensions', { count: node.extensions_count })}
</span>
</>
)}
</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">
<Button
variant="dark"
onClick={() => openAttachmentGroupChildModal(node.id)}
title={t('attachment.group_add_child')}
>
<i className="bi bi-folder-plus" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleAddExtensionForGroup(node.id)}
title={t('attachment.extension_add_button')}
>
<i className="bi bi-file-earmark-plus" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => openAttachmentGroupModal(node)}
title={t('acp.edit')}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleAttachmentGroupDelete(node)}
disabled={attachmentGroupSaving}
title={t('acp.delete')}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</div>
</div>
{isAttachmentGroupExpanded(node.id) && (
<div className="mb-2">
{node.children?.length > 0 && renderAttachmentGroupTree(node.children, depth + 1)}
{groupExtensions.length > 0 && (
<div
className="bb-attachment-extension-table"
style={{ marginLeft: (depth + 1) * 16 }}
>
<table className="table table-sm mb-0">
<thead className="tr-header">
<tr>
<th scope="col" className="text-start">
{t('attachment.extension')}
</th>
<th scope="col" className="text-start">
{t('attachment.allowed_mimes')}
</th>
<th scope="col" className="text-start">
{t('attachment.extension_group')}
</th>
<th scope="col" className="text-start">
{t('attachment.actions')}
</th>
</tr>
</thead>
<tbody>
{groupExtensions.map((extension) => (
<tr key={extension.id} className="bb-attachment-extension-row">
<td className="bb-attachment-extension-name text-start">
{extension.extension}
</td>
<td className="bb-attachment-extension-meta text-start">
{(extension.allowed_mimes || []).join(', ')}
</td>
<td className="bb-attachment-extension-meta text-start">
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|| t('attachment.extension_unassigned')}
</td>
<td>
<div className="bb-attachment-extension-actions">
<ButtonGroup size="sm" className="bb-action-group">
<Button
variant="dark"
onClick={() => openAttachmentExtensionEdit(extension)}
disabled={attachmentExtensionSaving}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleAttachmentExtensionDelete(extension)}
disabled={attachmentExtensionSaving}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
})
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')}>
<Row className="g-4">
<Col lg={3} xl={2}>
<div className="bb-acp-sidebar">
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.quick_access')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.users')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.groups')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.forums')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.ranks')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.attachments')}
</button>
</div>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.board_configuration')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action is-active">
{t('acp.general')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.forums')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.users')}
</button>
</div>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.client_communication')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.authentication')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.email_settings')}
</button>
</div>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.server_configuration')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.security_settings')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.search_settings')}
</button>
</div>
</div>
</div>
</Col>
<Col lg={9} xl={10}>
<div className="bb-acp-panel mb-4">
<div className="bb-acp-panel-header">
<h5 className="mb-1">{t('acp.welcome_title')}</h5>
<p className="bb-muted mb-0">{t('acp.general_hint')}</p>
</div>
</div>
<div className="bb-acp-panel mb-4">
<div className="bb-acp-panel-header">
<div className="d-flex align-items-center justify-content-between">
<h5 className="mb-0">{t('acp.statistics')}</h5>
<Button
type="button"
size="sm"
variant="dark"
onClick={refreshBoardStats}
disabled={boardStatsLoading}
>
{t('acp.refresh')}
</Button>
</div>
</div>
<div className="bb-acp-panel-body">
{boardStatsError && <p className="text-danger mb-2">{boardStatsError}</p>}
{boardStatsLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
{!boardStatsLoading && (
<div className="bb-acp-stats-grid">
<table className="bb-acp-stats-table">
<thead>
<tr>
<th>{t('stats.statistic')}</th>
<th>{t('stats.value')}</th>
</tr>
</thead>
<tbody>
{statsLeft.map((stat) => (
<tr key={stat.label}>
<td>{stat.label}</td>
<td className="bb-acp-stats-value">{stat.value}</td>
</tr>
))}
</tbody>
</table>
<table className="bb-acp-stats-table">
<thead>
<tr>
<th>{t('stats.statistic')}</th>
<th>{t('stats.value')}</th>
</tr>
</thead>
<tbody>
{statsRight.map((stat) => (
<tr key={stat.label}>
<td>{stat.label}</td>
<td className="bb-acp-stats-value">{stat.value}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{generalError && <p className="text-danger">{generalError}</p>}
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">{t('acp.general_settings')}</h5>
</div>
<div className="bb-acp-panel-body">
<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>
</div>
</div>
<div className="bb-acp-panel mt-4">
<div className="bb-acp-panel-header">
<div className="d-flex align-items-center justify-content-between">
<div>
<h5 className="mb-1">{t('acp.admin_log_title')}</h5>
<p className="bb-muted mb-0">{t('acp.admin_log_hint')}</p>
</div>
<Button
type="button"
size="sm"
variant="dark"
onClick={refreshAuditLogs}
disabled={auditLoading}
>
{t('acp.refresh')}
</Button>
</div>
</div>
<div className="bb-acp-panel-body">
{auditLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
{!auditLoading && recentAdminLogs.length === 0 && (
<p className="bb-muted mb-0">{t('admin_log.empty')}</p>
)}
{!auditLoading && recentAdminLogs.length > 0 && (
<div className="bb-acp-admin-log">
<table className="bb-acp-admin-log__table">
<thead>
<tr>
<th>{t('admin_log.username')}</th>
<th>{t('admin_log.user_ip')}</th>
<th>{t('admin_log.time')}</th>
<th>{t('admin_log.action')}</th>
</tr>
</thead>
<tbody>
{recentAdminLogs.map((entry) => (
<tr key={entry.id}>
<td>{entry.user?.name || entry.user?.email || '—'}</td>
<td>{entry.ip_address || '—'}</td>
<td>{formatDateTime(entry.created_at)}</td>
<td>{formatAuditAction(entry.action)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4}>
<button
type="button"
className="btn btn-link p-0"
onClick={() => {
const target = document.querySelector('[data-rb-event-key="audit"]')
if (target) target.click()
}}
>
{t('acp.view_admin_log')}
</button>
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
</div>
</Col>
</Row>
</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>
<Tab eventKey="attachments" title={t('acp.attachments')}>
{attachmentGroupsError && <p className="text-danger">{attachmentGroupsError}</p>}
{attachmentExtensionsError && <p className="text-danger">{attachmentExtensionsError}</p>}
<div className="bb-attachment-admin">
<div className="bb-attachment-admin-section">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">{t('attachment.settings_title')}</h5>
</div>
<Form onSubmit={handleAttachmentSettingsSave}>
<div className="row g-3 align-items-end">
<div className="col-12 col-md-6">
<Form.Check
type="switch"
id="attachment-display-inline"
label={t('attachment.display_images_inline')}
checked={attachmentSettings.display_images_inline !== 'false'}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
display_images_inline: event.target.checked ? 'true' : 'false',
}))
}
/>
</div>
<div className="col-12 col-md-6">
<Form.Check
type="switch"
id="attachment-create-thumbnails"
label={t('attachment.create_thumbnails')}
checked={attachmentSettings.create_thumbnails !== 'false'}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
create_thumbnails: event.target.checked ? 'true' : 'false',
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_max_width')}</Form.Label>
<Form.Control
type="number"
min="0"
value={attachmentSettings.thumbnail_max_width}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_max_width: event.target.value,
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_max_height')}</Form.Label>
<Form.Control
type="number"
min="0"
value={attachmentSettings.thumbnail_max_height}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_max_height: event.target.value,
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_quality')}</Form.Label>
<Form.Control
type="number"
min="10"
max="95"
value={attachmentSettings.thumbnail_quality}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_quality: event.target.value,
}))
}
/>
</div>
</div>
<div className="mt-3">
<Button
type="submit"
className="bb-accent-button"
disabled={attachmentSettingsSaving}
>
{attachmentSettingsSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</div>
<div className="bb-attachment-admin-section">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">{t('attachment.groups_title')}</h5>
<div className="d-flex align-items-center gap-2">
<Button
type="button"
className="bb-accent-button"
onClick={handleSeedAttachmentDefaults}
disabled={attachmentSeedSaving}
>
{attachmentSeedSaving
? t('attachment.seed_in_progress')
: t('attachment.seed_defaults')}
</Button>
<Button
type="button"
variant="outline-secondary"
onClick={handleAttachmentGroupExpandAll}
>
{t('acp.expand_all')}
</Button>
<Button
type="button"
variant="outline-secondary"
onClick={handleAttachmentGroupCollapseAll}
>
{t('acp.collapse_all')}
</Button>
<Button
type="button"
className="bb-accent-button"
onClick={() => openAttachmentGroupModal()}
>
{t('attachment.group_create')}
</Button>
</div>
</div>
{attachmentGroupsLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!attachmentGroupsLoading && attachmentGroups.length === 0 && (
<div className="bb-muted">
<p className="mb-2">{t('attachment.group_empty')}</p>
<Button
type="button"
className="bb-accent-button"
onClick={handleSeedAttachmentDefaults}
disabled={attachmentSeedSaving}
>
{attachmentSeedSaving
? t('attachment.seed_in_progress')
: t('attachment.seed_defaults')}
</Button>
<div className="bb-muted mt-2">
{t('attachment.seed_hint')}
</div>
</div>
)}
{!attachmentGroupsLoading && attachmentGroups.length > 0
&& attachmentGroups.every((group) => !group.parent_id) && (
<div className="bb-muted mb-3">
<Button
type="button"
className="bb-accent-button me-2"
onClick={handleSeedAttachmentDefaults}
disabled={attachmentSeedSaving}
>
{attachmentSeedSaving
? t('attachment.seed_in_progress')
: t('attachment.seed_defaults')}
</Button>
<Button
type="button"
variant="outline-secondary"
onClick={handleAttachmentGroupAutoNest}
disabled={attachmentSeedSaving}
>
{attachmentSeedSaving
? t('attachment.seed_in_progress')
: t('attachment.group_auto_nest')}
</Button>
<div className="bb-muted mt-2">
{t('attachment.group_auto_nest_hint')}
</div>
</div>
)}
{!attachmentGroupsLoading && attachmentGroups.length > 0 && (
<div className="bb-attachment-tree">
{renderAttachmentGroupTree(buildAttachmentGroupTree)}
</div>
)}
</div>
</div>
</Tab>
<Tab eventKey="audit" title={t('acp.audit_logs')}>
{auditError && <p className="text-danger">{auditError}</p>}
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<Form.Control
className="bb-user-search"
value={auditSearch}
onChange={(event) => setAuditSearch(event.target.value)}
placeholder={t('audit.search')}
/>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="number"
min="50"
max="500"
value={auditLimit}
onChange={(event) => setAuditLimit(Number(event.target.value) || 200)}
className="bb-audit-limit"
/>
<Button
type="button"
variant="dark"
onClick={refreshAuditLogs}
disabled={auditLoading}
>
{t('acp.refresh')}
</Button>
</div>
</div>
{auditLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!auditLoading && filteredAuditLogs.length === 0 && (
<p className="bb-muted">{t('audit.empty')}</p>
)}
{!auditLoading && filteredAuditLogs.length > 0 && (
<DataTable
columns={auditColumns}
data={filteredAuditLogs}
pagination
striped
highlightOnHover={themeMode !== 'dark'}
dense
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
customStyles={userTableStyles}
paginationComponentOptions={{
rowsPerPageText: t('table.rows_per_page'),
rangeSeparatorText: t('table.range_separator'),
}}
/>
)}
</Tab>
<Tab eventKey="system" title={t('acp.system')}>
{systemError && <p className="text-danger">{systemError}</p>}
{systemLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!systemLoading && systemStatus && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<div className="d-flex align-items-center justify-content-between">
<h5 className="mb-0">{t('system.requirements')}</h5>
<Button
type="button"
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('acp.refresh')}
</Button>
</div>
</div>
<div className="bb-acp-panel-body">
<table className="bb-acp-stats-table">
<thead>
<tr>
<th>{t('system.check')}</th>
<th>{t('system.path')}</th>
<th>{t('system.min_version')}</th>
<th>{t('system.current_version')}</th>
<th>{t('system.status')}</th>
<th>{t('system.recheck')}</th>
</tr>
</thead>
<tbody>
<tr>
<td>PHP</td>
<td className="bb-acp-stats-value">{systemStatus.php_selected_path || '—'}</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.php || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.php_selected_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon
status={systemStatus.php_selected_ok ? 'ok' : 'bad'}
/>
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>Composer</td>
<td className="bb-acp-stats-value">
{systemStatus.composer || t('system.not_found')}
</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.composer || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.composer_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.composer ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>Node</td>
<td className="bb-acp-stats-value">
{systemStatus.node || t('system.not_found')}
</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.node || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.node_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.node ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>npm</td>
<td className="bb-acp-stats-value">
{systemStatus.npm || t('system.not_found')}
</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.npm || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.npm_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.npm ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>tar</td>
<td className="bb-acp-stats-value">
{systemStatus.tar || t('system.not_found')}
</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
{systemStatus.tar_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.tar ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>rsync</td>
<td className="bb-acp-stats-value">
{systemStatus.rsync || t('system.not_found')}
</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
{systemStatus.rsync_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.rsync ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>proc_* functions</td>
<td className="bb-acp-stats-value" colSpan={3}>
{systemStatus.proc_functions
? Object.entries(systemStatus.proc_functions)
.filter(([, ok]) => !ok)
.map(([name]) => name)
.join(', ')
: '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon
status={
Boolean(systemStatus.proc_functions) &&
Object.values(systemStatus.proc_functions).every(Boolean)
? 'ok'
: 'bad'
}
/>
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>{t('system.storage_writable')}</td>
<td className="bb-acp-stats-value">storage/</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.storage_writable ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>{t('system.updates_writable')}</td>
<td className="bb-acp-stats-value">storage/app/updates</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.updates_writable ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
</tbody>
</table>
</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-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowUserModal(false)}
disabled={userSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" variant="dark" 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-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRoleModal(false)}
disabled={roleSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" variant="dark" 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-between mt-4">
<Button
type="submit"
className="bb-accent-button"
variant="dark"
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-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRankModal(false)}
disabled={rankSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" variant="dark" disabled={rankSaving}>
{rankSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal show={updateModalOpen} onHide={() => setUpdateModalOpen(false)} centered>
<Modal.Header closeButton>
<Modal.Title>{t('version.update_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="bb-muted mb-3">{t('version.update_hint')}</p>
{updateError && <p className="text-danger">{updateError}</p>}
{updateLog.length > 0 && (
<pre className="bb-acp-update-log">
{updateLog.join('\n')}
</pre>
)}
</Modal.Body>
<Modal.Footer className="justify-content-between">
<Button
variant="outline-secondary"
onClick={() => setUpdateModalOpen(false)}
disabled={updateRunning}
>
{t('acp.cancel')}
</Button>
<Button
className="bb-accent-button"
onClick={handleRunUpdate}
disabled={updateRunning}
>
{updateRunning ? t('version.updating') : t('version.update_now')}
</Button>
</Modal.Footer>
</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={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-between mt-4">
<Button
type="submit"
className="bb-accent-button"
variant="dark"
disabled={rankSaving || !rankFormName.trim()}
>
{rankSaving ? t('form.saving') : t('rank.create')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showAttachmentGroupModal}
onHide={() => setShowAttachmentGroupModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>
{attachmentGroupEdit.id
? t('attachment.group_edit_title')
: t('attachment.group_create_title')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{attachmentGroupsError && <p className="text-danger">{attachmentGroupsError}</p>}
<Form onSubmit={handleAttachmentGroupSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('attachment.group_name')}</Form.Label>
<Form.Control
value={attachmentGroupEdit.name}
onChange={(event) =>
setAttachmentGroupEdit((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('attachment.group_parent')}</Form.Label>
<Form.Select
value={attachmentGroupEdit.parentId}
onChange={(event) =>
setAttachmentGroupEdit((prev) => ({ ...prev, parentId: event.target.value }))
}
>
<option value="">{t('attachment.group_parent_none')}</option>
{attachmentGroupOptions
.filter((option) => String(option.id) !== String(attachmentGroupEdit.id || ''))
.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('attachment.group_max_size')}</Form.Label>
<Form.Control
type="number"
min="1"
max="512000"
value={attachmentGroupEdit.max_size_kb}
onChange={(event) =>
setAttachmentGroupEdit((prev) => ({
...prev,
max_size_kb: event.target.value,
}))
}
/>
<Form.Text className="bb-muted">
{t('attachment.group_max_size_hint')}
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Check
type="checkbox"
id="attachment-group-active"
label={t('attachment.group_active')}
checked={Boolean(attachmentGroupEdit.is_active)}
onChange={(event) =>
setAttachmentGroupEdit((prev) => ({
...prev,
is_active: event.target.checked,
}))
}
/>
</Form.Group>
<div className="d-flex justify-content-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowAttachmentGroupModal(false)}
disabled={attachmentGroupSaving}
>
{t('acp.cancel')}
</Button>
<Button
type="submit"
className="bb-accent-button"
variant="dark"
disabled={attachmentGroupSaving}
>
{attachmentGroupSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showAttachmentExtensionModal}
onHide={() => setShowAttachmentExtensionModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>
{attachmentExtensionEdit ? t('attachment.extension_edit') : t('attachment.extension_add')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{attachmentExtensionsError && <p className="text-danger">{attachmentExtensionsError}</p>}
<Form onSubmit={handleAttachmentExtensionCreate}>
<Form.Group className="mb-3">
<Form.Label>{t('attachment.extension')}</Form.Label>
<Form.Control
type="text"
value={newAttachmentExtension.extension}
onChange={(event) =>
setNewAttachmentExtension((prev) => ({
...prev,
extension: event.target.value,
}))
}
placeholder={t('attachment.extension_placeholder')}
ref={attachmentExtensionInputRef}
autoFocus
required
disabled={Boolean(attachmentExtensionEdit)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('attachment.allowed_mimes')}</Form.Label>
<Form.Control
type="text"
value={newAttachmentExtension.allowedMimes}
onChange={(event) =>
setNewAttachmentExtension((prev) => ({
...prev,
allowedMimes: event.target.value,
}))
}
placeholder={t('attachment.extension_mimes_placeholder')}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('attachment.extension_group')}</Form.Label>
<Form.Select
value={newAttachmentExtension.groupId}
onChange={(event) =>
setNewAttachmentExtension((prev) => ({
...prev,
groupId: event.target.value,
}))
}
>
<option value="">{t('attachment.extension_unassigned')}</option>
{attachmentGroups.map((group) => (
<option key={group.id} value={group.id}>
{group.is_active
? group.name
: `${group.name} (${t('attachment.inactive')})`}
</option>
))}
</Form.Select>
</Form.Group>
<div className="d-flex justify-content-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowAttachmentExtensionModal(false)}
disabled={attachmentExtensionSaving}
>
{t('acp.cancel')}
</Button>
<Button
type="submit"
className="bb-accent-button"
variant="dark"
disabled={attachmentExtensionSaving || !newAttachmentExtension.extension.trim()}
>
{attachmentExtensionSaving
? t('form.saving')
: attachmentExtensionEdit
? t('acp.save')
: t('attachment.extension_add')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showAttachmentExtensionDelete}
onHide={() => setShowAttachmentExtensionDelete(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('attachment.extension_delete_confirm')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="bb-muted">
{attachmentExtensionDeleteTarget?.extension}
</p>
<div className="d-flex justify-content-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowAttachmentExtensionDelete(false)}
disabled={attachmentExtensionSaving}
>
{t('acp.cancel')}
</Button>
<Button
type="button"
className="bb-accent-button"
variant="dark"
onClick={confirmAttachmentExtensionDelete}
disabled={attachmentExtensionSaving}
>
{attachmentExtensionSaving ? t('form.saving') : t('acp.delete')}
</Button>
</div>
</Modal.Body>
</Modal>
</Container>
)
}
export { Acp }
export default Acp