2524 lines
122 KiB
JavaScript
2524 lines
122 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
|
||
import DataTable, { createTheme } from 'react-data-table-component'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useDropzone } from 'react-dropzone'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import {
|
||
createForum,
|
||
deleteForum,
|
||
fetchSettings,
|
||
listAllForums,
|
||
listRanks,
|
||
listRoles,
|
||
listUsers,
|
||
reorderForums,
|
||
saveSetting,
|
||
saveSettings,
|
||
createRank,
|
||
deleteRank,
|
||
updateUserRank,
|
||
updateRank,
|
||
updateUser,
|
||
createRole,
|
||
updateRole,
|
||
deleteRole,
|
||
uploadRankBadgeImage,
|
||
uploadFavicon,
|
||
uploadLogo,
|
||
updateForum,
|
||
} from '../api/client'
|
||
|
||
export default function Acp({ isAdmin }) {
|
||
const { t } = useTranslation()
|
||
const { roles: authRoles } = useAuth()
|
||
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
|
||
const [forums, setForums] = useState([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [selectedId, setSelectedId] = useState(null)
|
||
const [draggingId, setDraggingId] = useState(null)
|
||
const [overId, setOverId] = useState(null)
|
||
const pendingOrder = useRef(null)
|
||
const [createType, setCreateType] = useState(null)
|
||
const [users, setUsers] = useState([])
|
||
const [userSearch, setUserSearch] = useState('')
|
||
const [usersLoading, setUsersLoading] = useState(false)
|
||
const [usersError, setUsersError] = useState('')
|
||
const [usersPage, setUsersPage] = useState(1)
|
||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||
const [ranks, setRanks] = useState([])
|
||
const [ranksLoading, setRanksLoading] = useState(false)
|
||
const [ranksError, setRanksError] = useState('')
|
||
const [rankUpdatingId, setRankUpdatingId] = useState(null)
|
||
const [rankFormName, setRankFormName] = useState('')
|
||
const [rankFormType, setRankFormType] = useState('text')
|
||
const [rankFormText, setRankFormText] = useState('')
|
||
const [rankFormColor, setRankFormColor] = useState('')
|
||
const [rankFormImage, setRankFormImage] = useState(null)
|
||
const [rankSaving, setRankSaving] = useState(false)
|
||
const [showRankCreate, setShowRankCreate] = useState(false)
|
||
const [showRankModal, setShowRankModal] = useState(false)
|
||
const [rankEdit, setRankEdit] = useState({
|
||
id: null,
|
||
name: '',
|
||
badgeType: 'text',
|
||
badgeText: '',
|
||
badgeImageUrl: '',
|
||
color: '',
|
||
})
|
||
const [rankEditImage, setRankEditImage] = useState(null)
|
||
const [showUserModal, setShowUserModal] = useState(false)
|
||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
|
||
const [roleQuery, setRoleQuery] = useState('')
|
||
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
|
||
const roleMenuRef = useRef(null)
|
||
const [roles, setRoles] = useState([])
|
||
const [rolesLoading, setRolesLoading] = useState(false)
|
||
const [rolesError, setRolesError] = useState('')
|
||
const [roleFormName, setRoleFormName] = useState('')
|
||
const [roleFormColor, setRoleFormColor] = useState('')
|
||
const [roleSaving, setRoleSaving] = useState(false)
|
||
const [showRoleCreate, setShowRoleCreate] = useState(false)
|
||
const [showRoleModal, setShowRoleModal] = useState(false)
|
||
const [roleEdit, setRoleEdit] = useState({
|
||
id: null,
|
||
name: '',
|
||
originalName: '',
|
||
color: '',
|
||
isCore: false,
|
||
})
|
||
const [userSaving, setUserSaving] = useState(false)
|
||
const [generalSaving, setGeneralSaving] = useState(false)
|
||
const [generalUploading, setGeneralUploading] = useState(false)
|
||
const [generalError, setGeneralError] = useState('')
|
||
const [generalSettings, setGeneralSettings] = useState({
|
||
forum_name: '',
|
||
default_theme: 'auto',
|
||
accent_color_dark: '',
|
||
accent_color_light: '',
|
||
logo_dark: '',
|
||
logo_light: '',
|
||
show_header_name: 'true',
|
||
favicon_ico: '',
|
||
favicon_16: '',
|
||
favicon_32: '',
|
||
favicon_48: '',
|
||
favicon_64: '',
|
||
favicon_128: '',
|
||
favicon_256: '',
|
||
})
|
||
const settingsDetailMap = {
|
||
forum_name: 'forumName',
|
||
default_theme: 'defaultTheme',
|
||
accent_color_dark: 'accentDark',
|
||
accent_color_light: 'accentLight',
|
||
logo_dark: 'logoDark',
|
||
logo_light: 'logoLight',
|
||
show_header_name: 'showHeaderName',
|
||
favicon_ico: 'faviconIco',
|
||
favicon_16: 'favicon16',
|
||
favicon_32: 'favicon32',
|
||
favicon_48: 'favicon48',
|
||
favicon_64: 'favicon64',
|
||
favicon_128: 'favicon128',
|
||
favicon_256: 'favicon256',
|
||
}
|
||
const [themeMode, setThemeMode] = useState(
|
||
document.documentElement.getAttribute('data-bs-theme') || 'light'
|
||
)
|
||
|
||
useEffect(() => {
|
||
createTheme('speedbb-dark', {
|
||
text: {
|
||
primary: '#e6e8eb',
|
||
secondary: '#9aa4b2',
|
||
},
|
||
background: {
|
||
default: 'transparent',
|
||
},
|
||
context: {
|
||
background: '#1a1f29',
|
||
text: '#ffffff',
|
||
},
|
||
divider: {
|
||
default: '#2a2f3a',
|
||
},
|
||
action: {
|
||
button: 'rgba(230, 232, 235, 0.12)',
|
||
hover: 'rgba(230, 232, 235, 0.08)',
|
||
disabled: 'rgba(230, 232, 235, 0.35)',
|
||
},
|
||
})
|
||
|
||
createTheme('speedbb-light', {
|
||
text: {
|
||
primary: '#0e121b',
|
||
secondary: '#5b6678',
|
||
},
|
||
background: {
|
||
default: '#ffffff',
|
||
},
|
||
context: {
|
||
background: '#f7f2ea',
|
||
text: '#0e121b',
|
||
},
|
||
divider: {
|
||
default: '#e0d7c7',
|
||
},
|
||
action: {
|
||
button: 'rgba(14, 18, 27, 0.12)',
|
||
hover: 'rgba(14, 18, 27, 0.06)',
|
||
disabled: 'rgba(14, 18, 27, 0.35)',
|
||
},
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!isAdmin) return
|
||
let active = true
|
||
const loadSettings = async () => {
|
||
try {
|
||
const allSettings = await fetchSettings()
|
||
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
|
||
if (!active) return
|
||
const next = {
|
||
forum_name: settingsMap.get('forum_name') || '',
|
||
default_theme: settingsMap.get('default_theme') || 'auto',
|
||
accent_color_dark: settingsMap.get('accent_color_dark') || '',
|
||
accent_color_light: settingsMap.get('accent_color_light') || '',
|
||
logo_dark: settingsMap.get('logo_dark') || '',
|
||
logo_light: settingsMap.get('logo_light') || '',
|
||
show_header_name: settingsMap.get('show_header_name') || 'true',
|
||
favicon_ico: settingsMap.get('favicon_ico') || '',
|
||
favicon_16: settingsMap.get('favicon_16') || '',
|
||
favicon_32: settingsMap.get('favicon_32') || '',
|
||
favicon_48: settingsMap.get('favicon_48') || '',
|
||
favicon_64: settingsMap.get('favicon_64') || '',
|
||
favicon_128: settingsMap.get('favicon_128') || '',
|
||
favicon_256: settingsMap.get('favicon_256') || '',
|
||
}
|
||
setGeneralSettings(next)
|
||
} catch (err) {
|
||
if (active) setGeneralError(err.message)
|
||
}
|
||
}
|
||
loadSettings()
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [isAdmin])
|
||
|
||
const handleGeneralSave = async (event) => {
|
||
event.preventDefault()
|
||
setGeneralSaving(true)
|
||
setGeneralError('')
|
||
try {
|
||
await saveSettings(
|
||
Object.entries(generalSettings).map(([key, value]) => ({
|
||
key,
|
||
value: typeof value === 'string' ? value.trim() : String(value ?? ''),
|
||
}))
|
||
)
|
||
const detail = Object.entries(generalSettings).reduce((acc, [key, value]) => {
|
||
const mappedKey = settingsDetailMap[key]
|
||
if (!mappedKey) return acc
|
||
if (key === 'show_header_name') {
|
||
acc[mappedKey] = value !== 'false'
|
||
return acc
|
||
}
|
||
acc[mappedKey] = typeof value === 'string' ? value.trim() : String(value ?? '')
|
||
return acc
|
||
}, {})
|
||
window.dispatchEvent(new CustomEvent('speedbb-settings-updated', { detail }))
|
||
} catch (err) {
|
||
setGeneralError(err.message)
|
||
} finally {
|
||
setGeneralSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleDefaultThemeChange = async (value) => {
|
||
const previous = generalSettings.default_theme
|
||
setGeneralSettings((prev) => ({ ...prev, default_theme: value }))
|
||
setGeneralError('')
|
||
try {
|
||
await saveSetting('default_theme', value)
|
||
window.dispatchEvent(
|
||
new CustomEvent('speedbb-settings-updated', {
|
||
detail: { defaultTheme: value },
|
||
})
|
||
)
|
||
} catch (err) {
|
||
setGeneralSettings((prev) => ({ ...prev, default_theme: previous }))
|
||
setGeneralError(err.message)
|
||
}
|
||
}
|
||
|
||
const handleLogoUpload = async (file, settingKey) => {
|
||
if (!file) return
|
||
setGeneralUploading(true)
|
||
setGeneralError('')
|
||
try {
|
||
const result = await uploadLogo(file)
|
||
const url = result?.url || ''
|
||
setGeneralSettings((prev) => ({ ...prev, [settingKey]: url }))
|
||
if (url) {
|
||
await saveSetting(settingKey, url)
|
||
const mappedKey = settingsDetailMap[settingKey]
|
||
if (mappedKey) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('speedbb-settings-updated', {
|
||
detail: { [mappedKey]: url },
|
||
})
|
||
)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setGeneralError(err.message)
|
||
} finally {
|
||
setGeneralUploading(false)
|
||
}
|
||
}
|
||
|
||
const handleFaviconUpload = async (file, settingKey) => {
|
||
if (!file) return
|
||
setGeneralUploading(true)
|
||
setGeneralError('')
|
||
try {
|
||
const result = await uploadFavicon(file)
|
||
const url = result?.url || ''
|
||
setGeneralSettings((prev) => ({ ...prev, [settingKey]: url }))
|
||
if (url) {
|
||
await saveSetting(settingKey, url)
|
||
const mappedKey = settingsDetailMap[settingKey]
|
||
if (mappedKey) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('speedbb-settings-updated', {
|
||
detail: { [mappedKey]: url },
|
||
})
|
||
)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setGeneralError(err.message)
|
||
} finally {
|
||
setGeneralUploading(false)
|
||
}
|
||
}
|
||
|
||
const faviconIcoDropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico'),
|
||
})
|
||
|
||
const favicon16Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16'),
|
||
})
|
||
|
||
const favicon32Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32'),
|
||
})
|
||
|
||
const favicon48Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48'),
|
||
})
|
||
|
||
const favicon64Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64'),
|
||
})
|
||
|
||
const favicon128Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128'),
|
||
})
|
||
|
||
const favicon256Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256'),
|
||
})
|
||
|
||
const darkLogoDropzone = useDropzone({
|
||
accept: {
|
||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleLogoUpload(files[0], 'logo_dark'),
|
||
})
|
||
|
||
const lightLogoDropzone = useDropzone({
|
||
accept: {
|
||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleLogoUpload(files[0], 'logo_light'),
|
||
})
|
||
|
||
useEffect(() => {
|
||
const observer = new MutationObserver(() => {
|
||
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
|
||
})
|
||
|
||
observer.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['data-bs-theme'],
|
||
})
|
||
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
const filteredUsers = useMemo(() => {
|
||
const term = userSearch.trim().toLowerCase()
|
||
if (!term) return users
|
||
return users.filter((user) =>
|
||
[user.name, user.email, user.rank?.name]
|
||
.filter(Boolean)
|
||
.some((value) => value.toLowerCase().includes(term))
|
||
)
|
||
}, [users, userSearch])
|
||
|
||
const userColumns = useMemo(
|
||
() => {
|
||
const iconFor = (id) => {
|
||
if (userSort.columnId !== id) {
|
||
return 'bi-arrow-down-up'
|
||
}
|
||
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
|
||
}
|
||
|
||
return [
|
||
{
|
||
id: 'name',
|
||
name: (
|
||
<span className="bb-sort-label">
|
||
{t('user.name')}
|
||
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
|
||
</span>
|
||
),
|
||
selector: (row) => row.name,
|
||
sortable: true,
|
||
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
|
||
sensitivity: 'base',
|
||
}),
|
||
},
|
||
{
|
||
id: 'email',
|
||
name: (
|
||
<span className="bb-sort-label">
|
||
{t('user.email')}
|
||
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
|
||
</span>
|
||
),
|
||
selector: (row) => row.email,
|
||
sortable: true,
|
||
},
|
||
{
|
||
id: 'rank',
|
||
name: (
|
||
<span className="bb-sort-label">
|
||
{t('user.rank')}
|
||
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
|
||
</span>
|
||
),
|
||
width: '220px',
|
||
sortable: true,
|
||
sortFunction: (a, b) =>
|
||
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
|
||
cell: (row) => (
|
||
<Form.Select
|
||
size="sm"
|
||
value={row.rank?.id ?? ''}
|
||
disabled={ranksLoading || rankUpdatingId === row.id}
|
||
onChange={async (event) => {
|
||
const nextRankId = event.target.value ? Number(event.target.value) : null
|
||
setRankUpdatingId(row.id)
|
||
try {
|
||
const updated = await updateUserRank(row.id, nextRankId)
|
||
setUsers((prev) =>
|
||
prev.map((user) =>
|
||
user.id === row.id ? { ...user, rank: updated.rank } : user
|
||
)
|
||
)
|
||
} catch (err) {
|
||
setUsersError(err.message)
|
||
} finally {
|
||
setRankUpdatingId(null)
|
||
}
|
||
}}
|
||
>
|
||
<option value="">{t('user.rank_unassigned')}</option>
|
||
{ranks.map((rank) => (
|
||
<option key={rank.id} value={rank.id}>
|
||
{rank.name}
|
||
</option>
|
||
))}
|
||
</Form.Select>
|
||
),
|
||
},
|
||
{
|
||
name: '',
|
||
width: '180px',
|
||
right: true,
|
||
cell: (row) => (
|
||
<div className="bb-user-actions">
|
||
<ButtonGroup className="bb-action-group">
|
||
<Button
|
||
variant="dark"
|
||
title={t('user.impersonate')}
|
||
onClick={() => console.log('impersonate', row)}
|
||
>
|
||
<i className="bi bi-person-badge" aria-hidden="true" />
|
||
</Button>
|
||
{(() => {
|
||
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
|
||
return (
|
||
<Button
|
||
variant="dark"
|
||
title={editLocked ? t('user.founder_locked') : t('user.edit')}
|
||
aria-disabled={editLocked}
|
||
className={editLocked ? 'bb-btn-disabled' : undefined}
|
||
onClick={() => {
|
||
if (editLocked) return
|
||
setUserForm({
|
||
id: row.id,
|
||
name: row.name,
|
||
email: row.email,
|
||
rankId: row.rank?.id ?? '',
|
||
roles: row.roles || [],
|
||
})
|
||
setShowUserModal(true)
|
||
setUsersError('')
|
||
}}
|
||
>
|
||
<i className="bi bi-pencil" aria-hidden="true" />
|
||
</Button>
|
||
)
|
||
})()}
|
||
<Button
|
||
variant="dark"
|
||
title={t('user.delete')}
|
||
onClick={() => console.log('delete user', row)}
|
||
>
|
||
<i className="bi bi-trash" aria-hidden="true" />
|
||
</Button>
|
||
</ButtonGroup>
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
},
|
||
[t, ranks, ranksLoading, rankUpdatingId, userSort]
|
||
)
|
||
const userTableStyles = useMemo(
|
||
() => ({
|
||
table: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? 'transparent' : '#ffffff',
|
||
},
|
||
},
|
||
headRow: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#f7f2ea',
|
||
borderBottomColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
|
||
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
|
||
fontWeight: 600,
|
||
},
|
||
},
|
||
rows: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : '#ffffff',
|
||
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
|
||
},
|
||
stripedStyle: {
|
||
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#f7f2ea',
|
||
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
|
||
},
|
||
highlightOnHoverStyle: {
|
||
backgroundColor: themeMode === 'dark'
|
||
? 'transparent'
|
||
: 'rgba(0, 0, 0, 0.04)',
|
||
},
|
||
},
|
||
pagination: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#ffffff',
|
||
color: themeMode === 'dark' ? '#cfd6df' : '#5b6678',
|
||
borderTopColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
|
||
},
|
||
},
|
||
}),
|
||
[themeMode]
|
||
)
|
||
|
||
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
|
||
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
|
||
const current = Math.min(usersPage, totalPages)
|
||
|
||
const pages = []
|
||
for (let page = Math.max(1, current - 2); page <= Math.min(totalPages, current + 2); page += 1) {
|
||
pages.push(page)
|
||
}
|
||
|
||
const goTo = (page) => {
|
||
if (page < 1 || page > totalPages) return
|
||
onChangePage(page, rowCount)
|
||
}
|
||
|
||
return (
|
||
<div className="bb-pagination">
|
||
<div className="bb-pagination-range">
|
||
{t('table.rows_per_page')} {rowsPerPage}{' '}
|
||
<span className="bb-muted">
|
||
{((current - 1) * rowsPerPage) + 1}-{Math.min(current * rowsPerPage, rowCount)} {t('table.range_separator')} {rowCount}
|
||
</span>
|
||
</div>
|
||
<div className="bb-pagination-actions">
|
||
<button type="button" onClick={() => goTo(1)} disabled={current === 1}>
|
||
«
|
||
</button>
|
||
<button type="button" onClick={() => goTo(current - 1)} disabled={current === 1}>
|
||
‹
|
||
</button>
|
||
{pages.map((page) => (
|
||
<button
|
||
key={page}
|
||
type="button"
|
||
className={page === current ? 'is-active' : ''}
|
||
onClick={() => goTo(page)}
|
||
>
|
||
{page}
|
||
</button>
|
||
))}
|
||
<button type="button" onClick={() => goTo(current + 1)} disabled={current === totalPages}>
|
||
›
|
||
</button>
|
||
<button type="button" onClick={() => goTo(totalPages)} disabled={current === totalPages}>
|
||
»
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
const [collapsed, setCollapsed] = useState(() => new Set())
|
||
const hasInitializedCollapse = useRef(false)
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
description: '',
|
||
type: 'category',
|
||
parentId: '',
|
||
})
|
||
|
||
const refreshForums = async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const data = await listAllForums()
|
||
setForums(data.filter((forum) => !forum.deleted_at))
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (isAdmin) {
|
||
refreshForums()
|
||
}
|
||
}, [isAdmin])
|
||
|
||
useEffect(() => {
|
||
if (!hasInitializedCollapse.current && forums.length > 0) {
|
||
const ids = forums
|
||
.filter((forum) => forum.type === 'category')
|
||
.map((forum) => String(forum.id))
|
||
setCollapsed(new Set(ids))
|
||
hasInitializedCollapse.current = true
|
||
}
|
||
}, [forums])
|
||
|
||
const refreshUsers = async () => {
|
||
setUsersLoading(true)
|
||
setUsersError('')
|
||
try {
|
||
const data = await listUsers()
|
||
setUsers(data)
|
||
} catch (err) {
|
||
setUsersError(err.message)
|
||
} finally {
|
||
setUsersLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (isAdmin) {
|
||
refreshUsers()
|
||
}
|
||
}, [isAdmin])
|
||
|
||
useEffect(() => {
|
||
if (!roleMenuOpen) return
|
||
const handleClick = (event) => {
|
||
if (!roleMenuRef.current) return
|
||
if (!roleMenuRef.current.contains(event.target)) {
|
||
setRoleMenuOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClick)
|
||
return () => document.removeEventListener('mousedown', handleClick)
|
||
}, [roleMenuOpen])
|
||
|
||
useEffect(() => {
|
||
if (!isAdmin) return
|
||
let active = true
|
||
const loadRoles = async () => {
|
||
setRolesLoading(true)
|
||
setRolesError('')
|
||
try {
|
||
const data = await listRoles()
|
||
if (active) setRoles(data)
|
||
} catch (err) {
|
||
if (active) setRolesError(err.message)
|
||
} finally {
|
||
if (active) setRolesLoading(false)
|
||
}
|
||
}
|
||
loadRoles()
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [isAdmin])
|
||
|
||
const refreshRanks = async () => {
|
||
setRanksLoading(true)
|
||
setRanksError('')
|
||
try {
|
||
const data = await listRanks()
|
||
setRanks(data)
|
||
} catch (err) {
|
||
setRanksError(err.message)
|
||
} finally {
|
||
setRanksLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (isAdmin) {
|
||
refreshRanks()
|
||
}
|
||
}, [isAdmin])
|
||
|
||
const handleCreateRank = async (event) => {
|
||
if (event?.preventDefault) {
|
||
event.preventDefault()
|
||
}
|
||
if (!rankFormName.trim()) return
|
||
if (rankFormType === 'image' && !rankFormImage) {
|
||
setRanksError(t('rank.badge_image_required'))
|
||
return
|
||
}
|
||
setRankSaving(true)
|
||
setRanksError('')
|
||
try {
|
||
const created = await createRank({
|
||
name: rankFormName.trim(),
|
||
badge_type: rankFormType,
|
||
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
||
color: rankFormColor.trim() || null,
|
||
})
|
||
let next = created
|
||
if (rankFormType === 'image' && rankFormImage) {
|
||
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
|
||
next = { ...created, ...updated }
|
||
}
|
||
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
|
||
setRankFormName('')
|
||
setRankFormType('text')
|
||
setRankFormText('')
|
||
setRankFormColor('')
|
||
setRankFormImage(null)
|
||
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 handleCreateRole = async (event) => {
|
||
event.preventDefault()
|
||
if (!roleFormName.trim()) return
|
||
setRoleSaving(true)
|
||
setRolesError('')
|
||
try {
|
||
const created = await createRole({
|
||
name: roleFormName.trim(),
|
||
color: roleFormColor.trim() || null,
|
||
})
|
||
setRoles((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
|
||
setRoleFormName('')
|
||
setRoleFormColor('')
|
||
setShowRoleCreate(false)
|
||
} catch (err) {
|
||
setRolesError(err.message)
|
||
} finally {
|
||
setRoleSaving(false)
|
||
}
|
||
}
|
||
|
||
const getParentId = (forum) => {
|
||
if (!forum.parent) return null
|
||
if (typeof forum.parent === 'string') {
|
||
return forum.parent.split('/').pop()
|
||
}
|
||
return forum.parent.id ?? null
|
||
}
|
||
|
||
const forumTree = useMemo(() => {
|
||
const map = new Map()
|
||
const roots = []
|
||
|
||
forums.forEach((forum) => {
|
||
map.set(String(forum.id), { ...forum, children: [] })
|
||
})
|
||
|
||
forums.forEach((forum) => {
|
||
const parentId = getParentId(forum)
|
||
const node = map.get(String(forum.id))
|
||
if (parentId && map.has(String(parentId))) {
|
||
map.get(String(parentId)).children.push(node)
|
||
} else {
|
||
roots.push(node)
|
||
}
|
||
})
|
||
|
||
const sortNodes = (nodes) => {
|
||
nodes.sort((a, b) => {
|
||
if (a.position !== b.position) return a.position - b.position
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
nodes.forEach((node) => sortNodes(node.children))
|
||
}
|
||
|
||
sortNodes(roots)
|
||
|
||
return { roots, map }
|
||
}, [forums])
|
||
|
||
const categoryOptions = useMemo(
|
||
() => forums.filter((forum) => forum.type === 'category'),
|
||
[forums]
|
||
)
|
||
|
||
const handleSelectForum = (forum) => {
|
||
const parentId =
|
||
typeof forum.parent === 'string'
|
||
? forum.parent.split('/').pop()
|
||
: forum.parent?.id ?? ''
|
||
setSelectedId(String(forum.id))
|
||
setShowModal(true)
|
||
setCreateType(null)
|
||
setForm({
|
||
name: forum.name || '',
|
||
description: forum.description || '',
|
||
type: forum.type || 'category',
|
||
parentId: parentId ? String(parentId) : '',
|
||
})
|
||
}
|
||
|
||
const handleReset = () => {
|
||
setSelectedId(null)
|
||
setShowModal(false)
|
||
setCreateType(null)
|
||
setForm({
|
||
name: '',
|
||
description: '',
|
||
type: 'category',
|
||
parentId: '',
|
||
})
|
||
}
|
||
|
||
const isExpanded = (forumId) => {
|
||
const key = String(forumId)
|
||
return !collapsed.has(key)
|
||
}
|
||
|
||
const toggleExpanded = (forumId) => {
|
||
const key = String(forumId)
|
||
setCollapsed((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(key)) {
|
||
next.delete(key)
|
||
} else {
|
||
next.add(key)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
const handleCollapseAll = () => {
|
||
const ids = forums
|
||
.filter((forum) => forum.type === 'category')
|
||
.map((forum) => String(forum.id))
|
||
setCollapsed(new Set(ids))
|
||
}
|
||
|
||
const handleExpandAll = () => {
|
||
setCollapsed(new Set())
|
||
}
|
||
|
||
const handleStartCreate = (type) => {
|
||
const current = selectedId
|
||
setSelectedId(null)
|
||
setShowModal(true)
|
||
setCreateType(type)
|
||
const parentFromSelection = current
|
||
? forums.find((forum) => String(forum.id) === String(current))
|
||
: null
|
||
const parentId =
|
||
parentFromSelection?.type === 'category' ? String(parentFromSelection.id) : ''
|
||
setForm({
|
||
name: '',
|
||
description: '',
|
||
type,
|
||
parentId,
|
||
})
|
||
}
|
||
|
||
const handleStartCreateChild = (type, parentId) => {
|
||
setSelectedId(null)
|
||
setShowModal(true)
|
||
setCreateType(type)
|
||
setForm({
|
||
name: '',
|
||
description: '',
|
||
type,
|
||
parentId: parentId ? String(parentId) : '',
|
||
})
|
||
}
|
||
|
||
const handleSubmit = async (event) => {
|
||
event.preventDefault()
|
||
setError('')
|
||
const trimmedName = form.name.trim()
|
||
if (!trimmedName) {
|
||
setError(t('acp.forums_name_required'))
|
||
return
|
||
}
|
||
if (form.type === 'forum' && !form.parentId) {
|
||
setError(t('acp.forums_parent_required'))
|
||
return
|
||
}
|
||
try {
|
||
if (selectedId) {
|
||
await updateForum(selectedId, {
|
||
name: trimmedName,
|
||
description: form.description,
|
||
type: form.type,
|
||
parentId: form.parentId || null,
|
||
})
|
||
} else {
|
||
await createForum({
|
||
name: trimmedName,
|
||
description: form.description,
|
||
type: form.type,
|
||
parentId: form.parentId || null,
|
||
})
|
||
}
|
||
handleReset()
|
||
refreshForums()
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (forumId) => {
|
||
setError('')
|
||
if (!confirm(t('acp.forums_confirm_delete'))) {
|
||
return
|
||
}
|
||
try {
|
||
await deleteForum(forumId)
|
||
if (selectedId === String(forumId)) {
|
||
handleReset()
|
||
}
|
||
refreshForums()
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}
|
||
|
||
const handleDragStart = (event, forumId) => {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
event.dataTransfer.setData('text/plain', String(forumId))
|
||
setDraggingId(String(forumId))
|
||
}
|
||
|
||
const handleDragEnd = () => {
|
||
if (pendingOrder.current) {
|
||
const { parentId, ordered } = pendingOrder.current
|
||
pendingOrder.current = null
|
||
reorderForums(parentId, ordered).catch((err) => setError(err.message))
|
||
}
|
||
setDraggingId(null)
|
||
setOverId(null)
|
||
}
|
||
|
||
const applyLocalOrder = (parentId, orderedIds) => {
|
||
setForums((prev) =>
|
||
prev.map((forum) => {
|
||
const pid = getParentId(forum)
|
||
if (String(pid ?? '') !== String(parentId ?? '')) {
|
||
return forum
|
||
}
|
||
const newIndex = orderedIds.indexOf(String(forum.id))
|
||
return newIndex === -1 ? forum : { ...forum, position: newIndex + 1 }
|
||
})
|
||
)
|
||
}
|
||
|
||
const handleDragOver = (event, targetId, parentId) => {
|
||
event.preventDefault()
|
||
event.dataTransfer.dropEffect = 'move'
|
||
if (!draggingId || String(draggingId) === String(targetId)) {
|
||
return
|
||
}
|
||
|
||
const draggedForum = forums.find((forum) => String(forum.id) === String(draggingId))
|
||
if (!draggedForum) {
|
||
return
|
||
}
|
||
|
||
const draggedParentId = getParentId(draggedForum)
|
||
if (String(draggedParentId ?? '') !== String(parentId ?? '')) {
|
||
return
|
||
}
|
||
|
||
const siblings = forums.filter((forum) => {
|
||
const pid = getParentId(forum)
|
||
return String(pid ?? '') === String(parentId ?? '')
|
||
})
|
||
|
||
const ordered = siblings
|
||
.slice()
|
||
.sort((a, b) => {
|
||
if (a.position !== b.position) return a.position - b.position
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
.map((forum) => String(forum.id))
|
||
|
||
const fromIndex = ordered.indexOf(String(draggingId))
|
||
const toIndex = ordered.indexOf(String(targetId))
|
||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||
return
|
||
}
|
||
|
||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||
setOverId(String(targetId))
|
||
applyLocalOrder(parentId, ordered)
|
||
pendingOrder.current = { parentId, ordered }
|
||
}
|
||
|
||
const handleDragEnter = (forumId) => {
|
||
if (draggingId && String(forumId) !== String(draggingId)) {
|
||
setOverId(String(forumId))
|
||
}
|
||
}
|
||
|
||
const handleDragLeave = (event, forumId) => {
|
||
if (event.currentTarget.contains(event.relatedTarget)) {
|
||
return
|
||
}
|
||
if (overId === String(forumId)) {
|
||
setOverId(null)
|
||
}
|
||
}
|
||
|
||
const handleDrop = async (event, targetId, parentId) => {
|
||
event.preventDefault()
|
||
const draggedId = event.dataTransfer.getData('text/plain')
|
||
if (!draggedId || String(draggedId) === String(targetId)) {
|
||
setDraggingId(null)
|
||
setOverId(null)
|
||
return
|
||
}
|
||
|
||
const siblings = forums.filter((forum) => {
|
||
const pid = getParentId(forum)
|
||
return String(pid ?? '') === String(parentId ?? '')
|
||
})
|
||
|
||
const ordered = siblings
|
||
.slice()
|
||
.sort((a, b) => {
|
||
if (a.position !== b.position) return a.position - b.position
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
.map((forum) => String(forum.id))
|
||
|
||
const fromIndex = ordered.indexOf(String(draggedId))
|
||
const toIndex = ordered.indexOf(String(targetId))
|
||
if (fromIndex === -1 || toIndex === -1) {
|
||
return
|
||
}
|
||
|
||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||
pendingOrder.current = null
|
||
|
||
try {
|
||
await reorderForums(parentId, ordered)
|
||
const updated = forums.map((forum) => {
|
||
const pid = getParentId(forum)
|
||
if (String(pid ?? '') !== String(parentId ?? '')) {
|
||
return forum
|
||
}
|
||
const newIndex = ordered.indexOf(String(forum.id))
|
||
return newIndex === -1 ? forum : { ...forum, position: newIndex + 1 }
|
||
})
|
||
setForums(updated)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setDraggingId(null)
|
||
setOverId(null)
|
||
}
|
||
}
|
||
|
||
const renderTree = (nodes, depth = 0) =>
|
||
nodes.map((node) => (
|
||
<div key={node.id}>
|
||
<div
|
||
className={`bb-drag-item d-flex align-items-center justify-content-between border rounded p-2 mb-2 ${
|
||
overId === String(node.id) ? 'bb-drop-target' : ''
|
||
} ${draggingId === String(node.id) ? 'bb-dragging' : ''}`}
|
||
style={{ marginLeft: depth * 16 }}
|
||
draggable
|
||
onDragStart={(event) => handleDragStart(event, node.id)}
|
||
onDragEnd={handleDragEnd}
|
||
onDragOver={(event) => handleDragOver(event, node.id, getParentId(node))}
|
||
onDragEnter={() => handleDragEnter(node.id)}
|
||
onDragLeave={(event) => handleDragLeave(event, node.id)}
|
||
onDrop={(event) => handleDrop(event, node.id, getParentId(node))}
|
||
>
|
||
<div className="d-flex align-items-start gap-3">
|
||
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
|
||
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
|
||
{node.type === 'category' && node.children?.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="bb-collapse-toggle"
|
||
onClick={() => toggleExpanded(node.id)}
|
||
aria-label={isExpanded(node.id) ? t('acp.collapse') : t('acp.expand')}
|
||
title={isExpanded(node.id) ? t('acp.collapse') : t('acp.expand')}
|
||
>
|
||
<i
|
||
className={`bi ${
|
||
isExpanded(node.id) ? 'bi-caret-down-fill' : 'bi-caret-right-fill'
|
||
}`}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
)}
|
||
</span>
|
||
<div>
|
||
<div className="fw-semibold d-flex align-items-center gap-2">
|
||
<span>{node.name}</span>
|
||
</div>
|
||
<div className="bb-muted">{node.description || ''}</div>
|
||
</div>
|
||
</div>
|
||
<div className="d-flex align-items-center gap-3">
|
||
<span
|
||
className="bb-drag-handle text-muted"
|
||
style={{ cursor: 'grab', display: 'inline-flex' }}
|
||
title={t('acp.drag_handle')}
|
||
>
|
||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||
</span>
|
||
<div className="bb-tree-action-group">
|
||
<ButtonGroup size="sm" className="bb-action-group w-100">
|
||
{node.type === 'category' && (
|
||
<>
|
||
<Button
|
||
variant="dark"
|
||
onClick={() => handleStartCreateChild('category', node.id)}
|
||
title={t('acp.add_category')}
|
||
>
|
||
<i className="bi bi-folder-plus" aria-hidden="true" />
|
||
</Button>
|
||
<Button
|
||
variant="dark"
|
||
onClick={() => handleStartCreateChild('forum', node.id)}
|
||
title={t('acp.add_forum')}
|
||
>
|
||
<i className="bi bi-chat-left-text" aria-hidden="true" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
|
||
<i className="bi bi-pencil" aria-hidden="true" />
|
||
</Button>
|
||
<Button variant="dark" onClick={() => handleDelete(node.id)} title={t('acp.delete')}>
|
||
<i className="bi bi-trash" aria-hidden="true" />
|
||
</Button>
|
||
</ButtonGroup>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{node.children?.length > 0 &&
|
||
(!node.type || node.type !== 'category' || isExpanded(node.id)) && (
|
||
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
|
||
)}
|
||
</div>
|
||
))
|
||
|
||
if (!isAdmin) {
|
||
return (
|
||
<Container fluid className="py-5">
|
||
<h2 className="mb-3">{t('acp.title')}</h2>
|
||
<p className="bb-muted">{t('acp.no_access')}</p>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Container fluid className="bb-acp py-4">
|
||
<h2 className="mb-4">{t('acp.title')}</h2>
|
||
<Tabs defaultActiveKey="general" className="mb-3">
|
||
<Tab eventKey="general" title={t('acp.general')}>
|
||
<p className="bb-muted">{t('acp.general_hint')}</p>
|
||
{generalError && <p className="text-danger">{generalError}</p>}
|
||
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
|
||
<Row className="g-3">
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.forum_name')}</Form.Label>
|
||
<Form.Control
|
||
type="text"
|
||
value={generalSettings.forum_name}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({ ...prev, forum_name: event.target.value }))
|
||
}
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mt-2">
|
||
<Form.Check
|
||
type="checkbox"
|
||
id="acp-show-header-name"
|
||
label={t('acp.show_header_name')}
|
||
checked={generalSettings.show_header_name !== 'false'}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
show_header_name: event.target.checked ? 'true' : 'false',
|
||
}))
|
||
}
|
||
/>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.default_theme')}</Form.Label>
|
||
<Form.Select
|
||
value={generalSettings.default_theme}
|
||
onChange={(event) => handleDefaultThemeChange(event.target.value)}
|
||
>
|
||
<option value="auto">{t('ucp.system_default')}</option>
|
||
<option value="dark">{t('nav.theme_dark')}</option>
|
||
<option value="light">{t('nav.theme_light')}</option>
|
||
</Form.Select>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.accent_dark')}</Form.Label>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<Form.Control
|
||
type="text"
|
||
value={generalSettings.accent_color_dark}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
accent_color_dark: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="#f29b3f"
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={generalSettings.accent_color_dark || '#f29b3f'}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
accent_color_dark: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.accent_light')}</Form.Label>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<Form.Control
|
||
type="text"
|
||
value={generalSettings.accent_color_light}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
accent_color_light: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="#f29b3f"
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={generalSettings.accent_color_light || '#f29b3f'}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
accent_color_light: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.logo_dark')}</Form.Label>
|
||
<div
|
||
{...darkLogoDropzone.getRootProps({
|
||
className: 'bb-dropzone',
|
||
})}
|
||
>
|
||
<input {...darkLogoDropzone.getInputProps()} />
|
||
{generalSettings.logo_dark ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.logo_dark} alt={t('acp.logo_dark')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.logo_light')}</Form.Label>
|
||
<div
|
||
{...lightLogoDropzone.getRootProps({
|
||
className: 'bb-dropzone',
|
||
})}
|
||
>
|
||
<input {...lightLogoDropzone.getInputProps()} />
|
||
{generalSettings.logo_light ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.logo_light} alt={t('acp.logo_light')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col xs={12}>
|
||
<Accordion className="bb-acp-accordion">
|
||
<Accordion.Item eventKey="favicons">
|
||
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
|
||
<Accordion.Body>
|
||
<Row className="g-3">
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
|
||
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...faviconIcoDropzone.getInputProps()} />
|
||
{generalSettings.favicon_ico ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_ico} alt={t('acp.favicon_ico')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_16')}</Form.Label>
|
||
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...favicon16Dropzone.getInputProps()} />
|
||
{generalSettings.favicon_16 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_16} alt={t('acp.favicon_16')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_32')}</Form.Label>
|
||
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...favicon32Dropzone.getInputProps()} />
|
||
{generalSettings.favicon_32 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_32} alt={t('acp.favicon_32')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_48')}</Form.Label>
|
||
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...favicon48Dropzone.getInputProps()} />
|
||
{generalSettings.favicon_48 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_48} alt={t('acp.favicon_48')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_64')}</Form.Label>
|
||
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...favicon64Dropzone.getInputProps()} />
|
||
{generalSettings.favicon_64 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_64} alt={t('acp.favicon_64')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_128')}</Form.Label>
|
||
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...favicon128Dropzone.getInputProps()} />
|
||
{generalSettings.favicon_128 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_128} alt={t('acp.favicon_128')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={4}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.favicon_256')}</Form.Label>
|
||
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||
<input {...favicon256Dropzone.getInputProps()} />
|
||
{generalSettings.favicon_256 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon_256} alt={t('acp.favicon_256')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
</Row>
|
||
</Accordion.Body>
|
||
</Accordion.Item>
|
||
</Accordion>
|
||
</Col>
|
||
<Col xs={12} className="d-flex justify-content-end">
|
||
<Button
|
||
type="submit"
|
||
className="bb-accent-button"
|
||
disabled={generalSaving || generalUploading}
|
||
>
|
||
{generalSaving ? t('form.saving') : t('acp.save')}
|
||
</Button>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</Tab>
|
||
<Tab eventKey="forums" title={t('acp.forums')}>
|
||
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
||
{error && <p className="text-danger">{error}</p>}
|
||
<Row className="g-4">
|
||
<Col lg={12}>
|
||
<div className="d-flex align-items-center justify-content-between mb-3 gap-3 flex-wrap">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<h5 className="mb-0">{t('acp.forums_tree')}</h5>
|
||
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleExpandAll}>
|
||
<i className="bi bi-arrows-expand me-1" aria-hidden="true" />
|
||
{t('acp.expand_all')}
|
||
</Button>
|
||
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleCollapseAll}>
|
||
<i className="bi bi-arrows-collapse me-1" aria-hidden="true" />
|
||
{t('acp.collapse_all')}
|
||
</Button>
|
||
</div>
|
||
<div className="d-flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline-dark"
|
||
className={`bb-acp-action ${createType === 'category' ? 'bb-acp-action--active' : ''}`}
|
||
onClick={() => handleStartCreate('category')}
|
||
>
|
||
<i className="bi bi-folder2 me-1" aria-hidden="true" />
|
||
{t('acp.new_category')}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline-dark"
|
||
className={`bb-acp-action ${createType === 'forum' ? 'bb-acp-action--active' : ''}`}
|
||
onClick={() => handleStartCreate('forum')}
|
||
>
|
||
<i className="bi bi-chat-left-text me-1" aria-hidden="true" />
|
||
{t('acp.new_forum')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{loading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||
{!loading && forumTree.roots.length === 0 && (
|
||
<p className="bb-muted">{t('acp.forums_empty')}</p>
|
||
)}
|
||
{forumTree.roots.length > 0 && (
|
||
<div className="mt-2">{renderTree(forumTree.roots)}</div>
|
||
)}
|
||
</Col>
|
||
</Row>
|
||
</Tab>
|
||
<Tab eventKey="users" title={t('acp.users')}>
|
||
{usersError && <p className="text-danger">{usersError}</p>}
|
||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||
{!usersLoading && (
|
||
<DataTable
|
||
columns={userColumns}
|
||
data={filteredUsers}
|
||
pagination
|
||
striped
|
||
highlightOnHover={themeMode !== 'dark'}
|
||
dense
|
||
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
||
customStyles={userTableStyles}
|
||
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
|
||
defaultSortFieldId="name"
|
||
onSort={(column, direction) => {
|
||
setUserSort({ columnId: column.id, direction })
|
||
}}
|
||
paginationComponentOptions={{
|
||
rowsPerPageText: t('table.rows_per_page'),
|
||
rangeSeparatorText: t('table.range_separator'),
|
||
}}
|
||
paginationPerPage={usersPerPage}
|
||
onChangePage={(page) => setUsersPage(page)}
|
||
onChangeRowsPerPage={(perPage) => {
|
||
setUsersPerPage(perPage)
|
||
setUsersPage(1)
|
||
}}
|
||
paginationComponent={UsersPagination}
|
||
subHeader
|
||
subHeaderComponent={
|
||
<Form.Control
|
||
className="bb-user-search"
|
||
value={userSearch}
|
||
onChange={(event) => setUserSearch(event.target.value)}
|
||
placeholder={t('user.search')}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
</Tab>
|
||
<Tab eventKey="groups" title={t('acp.groups')}>
|
||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||
<div className="d-flex justify-content-end mb-3">
|
||
<Button
|
||
type="button"
|
||
className="bb-accent-button"
|
||
onClick={() => setShowRoleCreate(true)}
|
||
>
|
||
{t('group.create')}
|
||
</Button>
|
||
</div>
|
||
{rolesLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||
{!rolesLoading && roles.length === 0 && (
|
||
<p className="bb-muted">{t('group.empty')}</p>
|
||
)}
|
||
{!rolesLoading && roles.length > 0 && (
|
||
<div className="bb-rank-list">
|
||
{roles.map((role) => {
|
||
const coreRole = isCoreRole(role.name)
|
||
return (
|
||
<div key={role.id} className="bb-rank-row">
|
||
<div className="bb-rank-main">
|
||
<span className="d-flex align-items-center gap-2">
|
||
{role.color && (
|
||
<span
|
||
className="bb-rank-color"
|
||
style={{ backgroundColor: role.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<span>{formatRoleLabel(role.name)}</span>
|
||
</span>
|
||
</div>
|
||
<div className="bb-rank-actions">
|
||
<Button
|
||
size="sm"
|
||
variant="dark"
|
||
title={coreRole ? t('group.core_locked') : t('group.edit')}
|
||
onClick={() => {
|
||
setRoleEdit({
|
||
id: role.id,
|
||
name: formatRoleLabel(role.name),
|
||
originalName: role.name,
|
||
color: role.color || '',
|
||
isCore: coreRole,
|
||
})
|
||
setShowRoleModal(true)
|
||
setRolesError('')
|
||
}}
|
||
>
|
||
<i className="bi bi-pencil" aria-hidden="true" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="dark"
|
||
disabled={coreRole}
|
||
title={coreRole ? t('group.core_locked') : t('group.delete')}
|
||
onClick={async () => {
|
||
if (coreRole) return
|
||
if (!window.confirm(t('group.delete_confirm'))) return
|
||
setRoleSaving(true)
|
||
setRolesError('')
|
||
try {
|
||
await deleteRole(role.id)
|
||
setRoles((prev) =>
|
||
prev.filter((item) => item.id !== role.id)
|
||
)
|
||
setUsers((prev) =>
|
||
prev.map((user) => ({
|
||
...user,
|
||
roles: (user.roles || []).filter(
|
||
(name) => name !== role.name
|
||
),
|
||
}))
|
||
)
|
||
} catch (err) {
|
||
setRolesError(err.message)
|
||
} finally {
|
||
setRoleSaving(false)
|
||
}
|
||
}}
|
||
>
|
||
<i className="bi bi-trash" aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</Tab>
|
||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||
<div className="d-flex justify-content-end mb-3">
|
||
<Button
|
||
type="button"
|
||
className="bb-accent-button"
|
||
onClick={() => setShowRankCreate(true)}
|
||
>
|
||
{t('rank.create')}
|
||
</Button>
|
||
</div>
|
||
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||
{!ranksLoading && ranks.length === 0 && (
|
||
<p className="bb-muted">{t('rank.empty')}</p>
|
||
)}
|
||
{!ranksLoading && ranks.length > 0 && (
|
||
<div className="bb-rank-list">
|
||
{ranks.map((rank) => (
|
||
<div key={rank.id} className="bb-rank-row">
|
||
<div className="bb-rank-main">
|
||
<span className="d-flex align-items-center gap-2">
|
||
{rank.color && (
|
||
<span
|
||
className="bb-rank-color"
|
||
style={{ backgroundColor: rank.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<span>{rank.name}</span>
|
||
</span>
|
||
</div>
|
||
<div className="bb-rank-actions">
|
||
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||
<img src={rank.badge_image_url} alt="" />
|
||
)}
|
||
{rank.badge_type !== 'image' && rank.badge_text && (
|
||
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="dark"
|
||
onClick={() => {
|
||
setRankEdit({
|
||
id: rank.id,
|
||
name: rank.name,
|
||
badgeType: rank.badge_type || 'text',
|
||
badgeText: rank.badge_text || '',
|
||
badgeImageUrl: rank.badge_image_url || '',
|
||
color: rank.color || '',
|
||
})
|
||
setRankEditImage(null)
|
||
setShowRankModal(true)
|
||
setRanksError('')
|
||
}}
|
||
>
|
||
<i className="bi bi-pencil" aria-hidden="true" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="dark"
|
||
onClick={async () => {
|
||
if (!window.confirm(t('rank.delete_confirm'))) return
|
||
setRankSaving(true)
|
||
setRanksError('')
|
||
try {
|
||
await deleteRank(rank.id)
|
||
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
|
||
} catch (err) {
|
||
setRanksError(err.message)
|
||
} finally {
|
||
setRankSaving(false)
|
||
}
|
||
}}
|
||
>
|
||
<i className="bi bi-trash" aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Tab>
|
||
</Tabs>
|
||
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
||
<Modal.Header closeButton closeVariant="white">
|
||
<Modal.Title>
|
||
{selectedId
|
||
? form.type === 'category'
|
||
? t('acp.forums_edit_category_title')
|
||
: t('acp.forums_edit_forum_title')
|
||
: createType === 'category'
|
||
? t('acp.forums_create_category_title')
|
||
: createType === 'forum'
|
||
? t('acp.forums_create_forum_title')
|
||
: t('acp.forums_create_title')}
|
||
</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
<p className="bb-muted">
|
||
{selectedId
|
||
? form.type === 'category'
|
||
? t('acp.forums_edit_category_hint')
|
||
: t('acp.forums_edit_forum_hint')
|
||
: createType === 'category'
|
||
? t('acp.forums_create_category_hint')
|
||
: createType === 'forum'
|
||
? t('acp.forums_create_forum_hint')
|
||
: t('acp.forums_form_hint')}
|
||
</p>
|
||
<Form onSubmit={handleSubmit}>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('form.title')}</Form.Label>
|
||
<Form.Control
|
||
type="text"
|
||
value={form.name}
|
||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||
required
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('form.description')}</Form.Label>
|
||
<Form.Control
|
||
as="textarea"
|
||
rows={3}
|
||
value={form.description}
|
||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||
/>
|
||
</Form.Group>
|
||
{selectedId && (
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('acp.forums_type')}</Form.Label>
|
||
<Form.Select
|
||
value={form.type}
|
||
onChange={(event) => setForm({ ...form, type: event.target.value })}
|
||
>
|
||
<option value="category">{t('forum.type_category')}</option>
|
||
<option value="forum">{t('forum.type_forum')}</option>
|
||
</Form.Select>
|
||
</Form.Group>
|
||
)}
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('acp.forums_parent')}</Form.Label>
|
||
<Form.Select
|
||
value={form.parentId}
|
||
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
|
||
>
|
||
<option value="" disabled={form.type === 'forum'}>
|
||
{t('acp.forums_parent_root')}
|
||
</option>
|
||
{categoryOptions
|
||
.filter((option) => String(option.id) !== String(selectedId))
|
||
.map((option) => (
|
||
<option key={option.id} value={option.id}>
|
||
{option.name}
|
||
</option>
|
||
))}
|
||
</Form.Select>
|
||
</Form.Group>
|
||
<div className="d-flex gap-2 justify-content-between">
|
||
<Button type="button" variant="outline-secondary" onClick={handleReset}>
|
||
{t('acp.cancel')}
|
||
</Button>
|
||
<Button type="submit" className="bb-accent-button">
|
||
{selectedId ? t('acp.save') : t('acp.create')}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Modal.Body>
|
||
</Modal>
|
||
<Modal
|
||
show={showUserModal}
|
||
onHide={() => setShowUserModal(false)}
|
||
centered
|
||
>
|
||
<Modal.Header closeButton>
|
||
<Modal.Title>{t('user.edit_title')}</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
{usersError && <p className="text-danger">{usersError}</p>}
|
||
<Form
|
||
onSubmit={async (event) => {
|
||
event.preventDefault()
|
||
if ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder) {
|
||
setUsersError(t('user.founder_locked'))
|
||
return
|
||
}
|
||
setUserSaving(true)
|
||
setUsersError('')
|
||
try {
|
||
const payload = {
|
||
name: userForm.name,
|
||
email: userForm.email,
|
||
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
||
}
|
||
if (roles.length) {
|
||
payload.roles = userForm.roles || []
|
||
}
|
||
const updated = await updateUser(userForm.id, payload)
|
||
setUsers((prev) =>
|
||
prev.map((user) =>
|
||
user.id === updated.id ? { ...user, ...updated } : user
|
||
)
|
||
)
|
||
setShowUserModal(false)
|
||
} catch (err) {
|
||
setUsersError(err.message)
|
||
} finally {
|
||
setUserSaving(false)
|
||
}
|
||
}}
|
||
>
|
||
{(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder && (
|
||
<p className="text-danger">{t('user.founder_locked')}</p>
|
||
)}
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('form.username')}</Form.Label>
|
||
<Form.Control
|
||
value={userForm.name}
|
||
onChange={(event) =>
|
||
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
||
}
|
||
required
|
||
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('form.email')}</Form.Label>
|
||
<Form.Control
|
||
type="email"
|
||
value={userForm.email}
|
||
onChange={(event) =>
|
||
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
||
}
|
||
required
|
||
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('user.rank')}</Form.Label>
|
||
<Form.Select
|
||
value={userForm.rankId ?? ''}
|
||
onChange={(event) =>
|
||
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
||
}
|
||
disabled={ranksLoading || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||
>
|
||
<option value="">{t('user.rank_unassigned')}</option>
|
||
{ranks.map((rank) => (
|
||
<option key={rank.id} value={rank.id}>
|
||
{rank.name}
|
||
</option>
|
||
))}
|
||
</Form.Select>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('user.roles')}</Form.Label>
|
||
<div className="bb-multiselect" ref={roleMenuRef}>
|
||
<button
|
||
type="button"
|
||
className="bb-multiselect__control"
|
||
onClick={() => setRoleMenuOpen((prev) => !prev)}
|
||
disabled={!roles.length || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||
>
|
||
<span className="bb-multiselect__value">
|
||
{(userForm.roles || []).length === 0 && (
|
||
<span className="bb-multiselect__placeholder">
|
||
{t('user.roles')}
|
||
</span>
|
||
)}
|
||
{(userForm.roles || []).map((roleName) => {
|
||
const role = roles.find((item) => item.name === roleName)
|
||
return (
|
||
<span key={roleName} className="bb-multiselect__chip">
|
||
{role?.color && (
|
||
<span
|
||
className="bb-multiselect__chip-color"
|
||
style={{ backgroundColor: role.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
{formatRoleLabel(roleName)}
|
||
{!(roleName === 'ROLE_FOUNDER' && !canManageFounder) && (
|
||
<span
|
||
className="bb-multiselect__chip-remove"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
setUserForm((prev) => ({
|
||
...prev,
|
||
roles: (prev.roles || []).filter(
|
||
(name) => name !== roleName
|
||
),
|
||
}))
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
×
|
||
</span>
|
||
)}
|
||
</span>
|
||
)
|
||
})}
|
||
</span>
|
||
<span className="bb-multiselect__caret" aria-hidden="true">
|
||
<i className="bi bi-chevron-down" />
|
||
</span>
|
||
</button>
|
||
{roleMenuOpen && (
|
||
<div className="bb-multiselect__menu">
|
||
<div className="bb-multiselect__search">
|
||
<input
|
||
type="text"
|
||
value={roleQuery}
|
||
onChange={(event) => setRoleQuery(event.target.value)}
|
||
placeholder={t('user.search')}
|
||
/>
|
||
</div>
|
||
<div className="bb-multiselect__options">
|
||
{filteredRoles.length === 0 && (
|
||
<div className="bb-multiselect__empty">
|
||
{t('rank.empty')}
|
||
</div>
|
||
)}
|
||
{filteredRoles.map((role) => {
|
||
const isSelected = (userForm.roles || []).includes(role.name)
|
||
const isFounderRole = role.name === 'ROLE_FOUNDER'
|
||
const isLocked = isFounderRole && !canManageFounder
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={role.id}
|
||
className={`bb-multiselect__option ${isSelected ? 'is-selected' : ''}`}
|
||
onClick={() =>
|
||
setUserForm((prev) => {
|
||
if (isLocked) {
|
||
return prev
|
||
}
|
||
const next = new Set(prev.roles || [])
|
||
if (next.has(role.name)) {
|
||
next.delete(role.name)
|
||
} else {
|
||
next.add(role.name)
|
||
}
|
||
return { ...prev, roles: Array.from(next) }
|
||
})
|
||
}
|
||
disabled={isLocked}
|
||
>
|
||
<span className="bb-multiselect__option-main">
|
||
{role.color && (
|
||
<span
|
||
className="bb-multiselect__chip-color"
|
||
style={{ backgroundColor: role.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
{formatRoleLabel(role.name)}
|
||
</span>
|
||
{isSelected && (
|
||
<i className="bi bi-check-lg" aria-hidden="true" />
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
<div className="d-flex justify-content-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline-secondary"
|
||
onClick={() => setShowUserModal(false)}
|
||
disabled={userSaving}
|
||
>
|
||
{t('acp.cancel')}
|
||
</Button>
|
||
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
|
||
{userSaving ? t('form.saving') : t('acp.save')}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Modal.Body>
|
||
</Modal>
|
||
<Modal
|
||
show={showRoleModal}
|
||
onHide={() => setShowRoleModal(false)}
|
||
centered
|
||
>
|
||
<Modal.Header closeButton>
|
||
<Modal.Title>{t('group.edit_title')}</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||
<Form
|
||
onSubmit={async (event) => {
|
||
event.preventDefault()
|
||
if (!roleEdit.name.trim()) return
|
||
setRoleSaving(true)
|
||
setRolesError('')
|
||
try {
|
||
const updated = await updateRole(roleEdit.id, {
|
||
name: roleEdit.name.trim(),
|
||
color: roleEdit.color.trim() || null,
|
||
})
|
||
setRoles((prev) =>
|
||
prev
|
||
.map((item) => (item.id === updated.id ? updated : item))
|
||
.sort((a, b) => a.name.localeCompare(b.name))
|
||
)
|
||
if (roleEdit.originalName && roleEdit.originalName !== updated.name) {
|
||
setUsers((prev) =>
|
||
prev.map((user) => ({
|
||
...user,
|
||
roles: (user.roles || []).map((name) =>
|
||
name === roleEdit.originalName ? updated.name : name
|
||
),
|
||
}))
|
||
)
|
||
}
|
||
setShowRoleModal(false)
|
||
} catch (err) {
|
||
setRolesError(err.message)
|
||
} finally {
|
||
setRoleSaving(false)
|
||
}
|
||
}}
|
||
>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('group.name')}</Form.Label>
|
||
<Form.Control
|
||
value={roleEdit.name}
|
||
onChange={(event) =>
|
||
setRoleEdit((prev) => ({ ...prev, name: event.target.value }))
|
||
}
|
||
disabled={roleEdit.isCore}
|
||
required
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('group.color')}</Form.Label>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<Form.Control
|
||
value={roleEdit.color}
|
||
onChange={(event) =>
|
||
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
|
||
}
|
||
placeholder={t('group.color_placeholder')}
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={roleEdit.color || '#f29b3f'}
|
||
onChange={(event) =>
|
||
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
</Form.Group>
|
||
<div className="d-flex justify-content-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline-secondary"
|
||
onClick={() => setShowRoleModal(false)}
|
||
disabled={roleSaving}
|
||
>
|
||
{t('acp.cancel')}
|
||
</Button>
|
||
<Button type="submit" className="bb-accent-button" disabled={roleSaving}>
|
||
{roleSaving ? t('form.saving') : t('acp.save')}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Modal.Body>
|
||
</Modal>
|
||
<Modal
|
||
show={showRoleCreate}
|
||
onHide={() => setShowRoleCreate(false)}
|
||
centered
|
||
>
|
||
<Modal.Header closeButton>
|
||
<Modal.Title>{t('group.create_title')}</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||
<Form onSubmit={handleCreateRole}>
|
||
<Form.Group>
|
||
<Form.Label>{t('group.name')}</Form.Label>
|
||
<Form.Control
|
||
value={roleFormName}
|
||
onChange={(event) => setRoleFormName(event.target.value)}
|
||
placeholder={t('group.name_placeholder')}
|
||
disabled={roleSaving}
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mt-3">
|
||
<Form.Label>{t('group.color')}</Form.Label>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<Form.Control
|
||
value={roleFormColor}
|
||
onChange={(event) => setRoleFormColor(event.target.value)}
|
||
placeholder={t('group.color_placeholder')}
|
||
disabled={roleSaving}
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={roleFormColor || '#f29b3f'}
|
||
onChange={(event) => setRoleFormColor(event.target.value)}
|
||
disabled={roleSaving}
|
||
/>
|
||
</div>
|
||
</Form.Group>
|
||
<div className="d-flex justify-content-end mt-4">
|
||
<Button
|
||
type="submit"
|
||
className="bb-accent-button"
|
||
disabled={roleSaving || !roleFormName.trim()}
|
||
>
|
||
{roleSaving ? t('form.saving') : t('group.create')}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Modal.Body>
|
||
</Modal>
|
||
<Modal
|
||
show={showRankModal}
|
||
onHide={() => setShowRankModal(false)}
|
||
centered
|
||
>
|
||
<Modal.Header closeButton>
|
||
<Modal.Title>{t('rank.edit_title')}</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||
<Form
|
||
onSubmit={async (event) => {
|
||
event.preventDefault()
|
||
if (!rankEdit.name.trim()) return
|
||
if (rankEdit.badgeType === 'text' && !rankEdit.badgeText.trim()) {
|
||
setRanksError(t('rank.badge_text_required'))
|
||
return
|
||
}
|
||
setRankSaving(true)
|
||
setRanksError('')
|
||
try {
|
||
const updated = await updateRank(rankEdit.id, {
|
||
name: rankEdit.name.trim(),
|
||
badge_type: rankEdit.badgeType,
|
||
badge_text:
|
||
rankEdit.badgeType === 'text'
|
||
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
||
: null,
|
||
color: rankEdit.color.trim() || null,
|
||
})
|
||
let next = updated
|
||
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
||
const upload = await uploadRankBadgeImage(rankEdit.id, rankEditImage)
|
||
next = { ...updated, ...upload }
|
||
}
|
||
setRanks((prev) =>
|
||
prev
|
||
.map((item) => (item.id === next.id ? { ...item, ...next } : item))
|
||
.sort((a, b) => a.name.localeCompare(b.name))
|
||
)
|
||
setShowRankModal(false)
|
||
} catch (err) {
|
||
setRanksError(err.message)
|
||
} finally {
|
||
setRankSaving(false)
|
||
}
|
||
}}
|
||
>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('rank.name')}</Form.Label>
|
||
<Form.Control
|
||
value={rankEdit.name}
|
||
onChange={(event) =>
|
||
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
|
||
}
|
||
required
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('rank.color')}</Form.Label>
|
||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||
<Form.Check
|
||
type="checkbox"
|
||
id="rank-edit-color-default"
|
||
label={t('rank.color_default')}
|
||
checked={!rankEdit.color}
|
||
onChange={(event) =>
|
||
setRankEdit((prev) => ({
|
||
...prev,
|
||
color: event.target.checked ? '' : prev.color || '#f29b3f',
|
||
}))
|
||
}
|
||
/>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<Form.Control
|
||
value={rankEdit.color}
|
||
onChange={(event) =>
|
||
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
|
||
}
|
||
placeholder={t('rank.color_placeholder')}
|
||
disabled={!rankEdit.color}
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={rankEdit.color || '#f29b3f'}
|
||
onChange={(event) =>
|
||
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
|
||
}
|
||
disabled={!rankEdit.color}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Form.Group>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||
<div className="d-flex gap-3 flex-wrap">
|
||
<Form.Check
|
||
type="radio"
|
||
id="rank-edit-badge-none"
|
||
name="rankEditBadgeType"
|
||
label={t('rank.badge_none')}
|
||
checked={rankEdit.badgeType === 'none'}
|
||
onChange={() =>
|
||
setRankEdit((prev) => ({ ...prev, badgeType: 'none' }))
|
||
}
|
||
/>
|
||
<Form.Check
|
||
type="radio"
|
||
id="rank-edit-badge-text"
|
||
name="rankEditBadgeType"
|
||
label={t('rank.badge_text')}
|
||
checked={rankEdit.badgeType === 'text'}
|
||
onChange={() =>
|
||
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
|
||
}
|
||
/>
|
||
<Form.Check
|
||
type="radio"
|
||
id="rank-edit-badge-image"
|
||
name="rankEditBadgeType"
|
||
label={t('rank.badge_image')}
|
||
checked={rankEdit.badgeType === 'image'}
|
||
onChange={() =>
|
||
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
|
||
}
|
||
/>
|
||
</div>
|
||
</Form.Group>
|
||
{rankEdit.badgeType === 'text' && (
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||
<Form.Control
|
||
value={rankEdit.badgeText}
|
||
onChange={(event) =>
|
||
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
|
||
}
|
||
placeholder={t('rank.badge_text_placeholder')}
|
||
required
|
||
/>
|
||
</Form.Group>
|
||
)}
|
||
{rankEdit.badgeType === 'image' && (
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||
{rankEdit.badgeImageUrl && !rankEditImage && (
|
||
<div className="bb-rank-badge-preview">
|
||
<img src={rankEdit.badgeImageUrl} alt="" />
|
||
</div>
|
||
)}
|
||
<Form.Control
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
|
||
/>
|
||
</Form.Group>
|
||
)}
|
||
<div className="d-flex justify-content-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline-secondary"
|
||
onClick={() => setShowRankModal(false)}
|
||
disabled={rankSaving}
|
||
>
|
||
{t('acp.cancel')}
|
||
</Button>
|
||
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
|
||
{rankSaving ? t('form.saving') : t('acp.save')}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Modal.Body>
|
||
</Modal>
|
||
<Modal
|
||
show={showRankCreate}
|
||
onHide={() => setShowRankCreate(false)}
|
||
centered
|
||
>
|
||
<Modal.Header closeButton>
|
||
<Modal.Title>{t('rank.create_title')}</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||
<Form onSubmit={handleCreateRank}>
|
||
<Form.Group>
|
||
<Form.Label>{t('rank.name')}</Form.Label>
|
||
<Form.Control
|
||
value={rankFormName}
|
||
onChange={(event) => setRankFormName(event.target.value)}
|
||
placeholder={t('rank.name_placeholder')}
|
||
disabled={rankSaving}
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mt-3">
|
||
<Form.Label>{t('rank.color')}</Form.Label>
|
||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||
<Form.Check
|
||
type="checkbox"
|
||
id="rank-create-color-default"
|
||
label={t('rank.color_default')}
|
||
checked={!rankFormColor}
|
||
onChange={(event) =>
|
||
setRankFormColor(event.target.checked ? '' : '#f29b3f')
|
||
}
|
||
disabled={rankSaving}
|
||
/>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<Form.Control
|
||
value={rankFormColor}
|
||
onChange={(event) => setRankFormColor(event.target.value)}
|
||
placeholder={t('rank.color_placeholder')}
|
||
disabled={rankSaving || !rankFormColor}
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={rankFormColor || '#f29b3f'}
|
||
onChange={(event) => setRankFormColor(event.target.value)}
|
||
disabled={rankSaving || !rankFormColor}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Form.Group>
|
||
<Form.Group className="mt-3">
|
||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||
<div className="d-flex gap-3 flex-wrap">
|
||
<Form.Check
|
||
type="radio"
|
||
id="rank-badge-none"
|
||
name="rankBadgeType"
|
||
label={t('rank.badge_none')}
|
||
checked={rankFormType === 'none'}
|
||
onChange={() => setRankFormType('none')}
|
||
/>
|
||
<Form.Check
|
||
type="radio"
|
||
id="rank-badge-text"
|
||
name="rankBadgeType"
|
||
label={t('rank.badge_text')}
|
||
checked={rankFormType === 'text'}
|
||
onChange={() => setRankFormType('text')}
|
||
/>
|
||
<Form.Check
|
||
type="radio"
|
||
id="rank-badge-image"
|
||
name="rankBadgeType"
|
||
label={t('rank.badge_image')}
|
||
checked={rankFormType === 'image'}
|
||
onChange={() => setRankFormType('image')}
|
||
/>
|
||
</div>
|
||
</Form.Group>
|
||
{rankFormType === 'text' && (
|
||
<Form.Group className="mt-3">
|
||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||
<Form.Control
|
||
value={rankFormText}
|
||
onChange={(event) => setRankFormText(event.target.value)}
|
||
placeholder={t('rank.badge_text_placeholder')}
|
||
disabled={rankSaving}
|
||
/>
|
||
</Form.Group>
|
||
)}
|
||
{rankFormType === 'image' && (
|
||
<Form.Group className="mt-3">
|
||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||
<Form.Control
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||
disabled={rankSaving}
|
||
/>
|
||
</Form.Group>
|
||
)}
|
||
<div className="d-flex justify-content-end mt-4">
|
||
<Button
|
||
type="submit"
|
||
className="bb-accent-button"
|
||
disabled={rankSaving || !rankFormName.trim()}
|
||
>
|
||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Modal.Body>
|
||
</Modal>
|
||
</Container>
|
||
)
|
||
}
|