4743 lines
235 KiB
JavaScript
4743 lines
235 KiB
JavaScript
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
|