1421 lines
49 KiB
JavaScript
1421 lines
49 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
|
||
import DataTable, { createTheme } from 'react-data-table-component'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useDropzone } from 'react-dropzone'
|
||
import {
|
||
createForum,
|
||
deleteForum,
|
||
fetchSetting,
|
||
listAllForums,
|
||
listUsers,
|
||
reorderForums,
|
||
saveSetting,
|
||
uploadFavicon,
|
||
uploadLogo,
|
||
updateForum,
|
||
} from '../api/client'
|
||
|
||
export default function Acp({ isAdmin }) {
|
||
const { t } = useTranslation()
|
||
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 [usersLoading, setUsersLoading] = useState(false)
|
||
const [usersError, setUsersError] = useState('')
|
||
const [usersPage, setUsersPage] = useState(1)
|
||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||
const [generalSaving, setGeneralSaving] = useState(false)
|
||
const [generalUploading, setGeneralUploading] = useState(false)
|
||
const [generalError, setGeneralError] = useState('')
|
||
const [generalSettings, setGeneralSettings] = useState({
|
||
forumName: '',
|
||
defaultTheme: 'auto',
|
||
darkAccent: '',
|
||
lightAccent: '',
|
||
darkLogo: '',
|
||
lightLogo: '',
|
||
showHeaderName: true,
|
||
faviconIco: '',
|
||
favicon16: '',
|
||
favicon32: '',
|
||
favicon48: '',
|
||
favicon64: '',
|
||
favicon128: '',
|
||
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 keys = [
|
||
'forum_name',
|
||
'default_theme',
|
||
'accent_color_dark',
|
||
'accent_color_light',
|
||
'logo_dark',
|
||
'logo_light',
|
||
'show_header_name',
|
||
'favicon_ico',
|
||
'favicon_16',
|
||
'favicon_32',
|
||
'favicon_48',
|
||
'favicon_64',
|
||
'favicon_128',
|
||
'favicon_256',
|
||
]
|
||
const results = await Promise.all(keys.map((key) => fetchSetting(key)))
|
||
if (!active) return
|
||
const next = {
|
||
forumName: results[0]?.value || '',
|
||
defaultTheme: results[1]?.value || 'auto',
|
||
darkAccent: results[2]?.value || '',
|
||
lightAccent: results[3]?.value || '',
|
||
darkLogo: results[4]?.value || '',
|
||
lightLogo: results[5]?.value || '',
|
||
showHeaderName: results[6]?.value !== 'false',
|
||
faviconIco: results[7]?.value || '',
|
||
favicon16: results[8]?.value || '',
|
||
favicon32: results[9]?.value || '',
|
||
favicon48: results[10]?.value || '',
|
||
favicon64: results[11]?.value || '',
|
||
favicon128: results[12]?.value || '',
|
||
favicon256: results[13]?.value || '',
|
||
}
|
||
setGeneralSettings(next)
|
||
} catch (err) {
|
||
if (active) setGeneralError(err.message)
|
||
}
|
||
}
|
||
loadSettings()
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [isAdmin])
|
||
|
||
const handleGeneralSave = async (event) => {
|
||
event.preventDefault()
|
||
setGeneralSaving(true)
|
||
setGeneralError('')
|
||
try {
|
||
await Promise.all([
|
||
saveSetting('forum_name', generalSettings.forumName.trim() || ''),
|
||
saveSetting('default_theme', generalSettings.defaultTheme || 'auto'),
|
||
saveSetting('accent_color_dark', generalSettings.darkAccent.trim() || ''),
|
||
saveSetting('accent_color_light', generalSettings.lightAccent.trim() || ''),
|
||
saveSetting('logo_dark', generalSettings.darkLogo.trim() || ''),
|
||
saveSetting('logo_light', generalSettings.lightLogo.trim() || ''),
|
||
saveSetting('show_header_name', generalSettings.showHeaderName ? 'true' : 'false'),
|
||
saveSetting('favicon_ico', generalSettings.faviconIco.trim() || ''),
|
||
saveSetting('favicon_16', generalSettings.favicon16.trim() || ''),
|
||
saveSetting('favicon_32', generalSettings.favicon32.trim() || ''),
|
||
saveSetting('favicon_48', generalSettings.favicon48.trim() || ''),
|
||
saveSetting('favicon_64', generalSettings.favicon64.trim() || ''),
|
||
saveSetting('favicon_128', generalSettings.favicon128.trim() || ''),
|
||
saveSetting('favicon_256', generalSettings.favicon256.trim() || ''),
|
||
])
|
||
window.dispatchEvent(
|
||
new CustomEvent('speedbb-settings-updated', {
|
||
detail: {
|
||
forumName: generalSettings.forumName.trim() || '',
|
||
defaultTheme: generalSettings.defaultTheme || 'auto',
|
||
accentDark: generalSettings.darkAccent.trim() || '',
|
||
accentLight: generalSettings.lightAccent.trim() || '',
|
||
logoDark: generalSettings.darkLogo.trim() || '',
|
||
logoLight: generalSettings.lightLogo.trim() || '',
|
||
showHeaderName: generalSettings.showHeaderName,
|
||
faviconIco: generalSettings.faviconIco.trim() || '',
|
||
favicon16: generalSettings.favicon16.trim() || '',
|
||
favicon32: generalSettings.favicon32.trim() || '',
|
||
favicon48: generalSettings.favicon48.trim() || '',
|
||
favicon64: generalSettings.favicon64.trim() || '',
|
||
favicon128: generalSettings.favicon128.trim() || '',
|
||
favicon256: generalSettings.favicon256.trim() || '',
|
||
},
|
||
})
|
||
)
|
||
} catch (err) {
|
||
setGeneralError(err.message)
|
||
} finally {
|
||
setGeneralSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleDefaultThemeChange = async (value) => {
|
||
const previous = generalSettings.defaultTheme
|
||
setGeneralSettings((prev) => ({ ...prev, defaultTheme: value }))
|
||
setGeneralError('')
|
||
try {
|
||
await saveSetting('default_theme', value)
|
||
window.dispatchEvent(
|
||
new CustomEvent('speedbb-settings-updated', {
|
||
detail: { defaultTheme: value },
|
||
})
|
||
)
|
||
} catch (err) {
|
||
setGeneralSettings((prev) => ({ ...prev, defaultTheme: previous }))
|
||
setGeneralError(err.message)
|
||
}
|
||
}
|
||
|
||
const handleLogoUpload = async (file, variantKey) => {
|
||
if (!file) return
|
||
setGeneralUploading(true)
|
||
setGeneralError('')
|
||
try {
|
||
const result = await uploadLogo(file)
|
||
const url = result?.url || ''
|
||
const settingKey = variantKey === 'darkLogo' ? 'logo_dark' : 'logo_light'
|
||
setGeneralSettings((prev) => ({ ...prev, [variantKey]: url }))
|
||
if (url) {
|
||
await saveSetting(settingKey, url)
|
||
}
|
||
} catch (err) {
|
||
setGeneralError(err.message)
|
||
} finally {
|
||
setGeneralUploading(false)
|
||
}
|
||
}
|
||
|
||
const handleFaviconUpload = async (file, settingKey, stateKey) => {
|
||
if (!file) return
|
||
setGeneralUploading(true)
|
||
setGeneralError('')
|
||
try {
|
||
const result = await uploadFavicon(file)
|
||
const url = result?.url || ''
|
||
setGeneralSettings((prev) => ({ ...prev, [stateKey]: url }))
|
||
if (url) {
|
||
await saveSetting(settingKey, url)
|
||
window.dispatchEvent(
|
||
new CustomEvent('speedbb-settings-updated', {
|
||
detail: { [stateKey]: 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', 'faviconIco'),
|
||
})
|
||
|
||
const favicon16Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16', 'favicon16'),
|
||
})
|
||
|
||
const favicon32Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32', 'favicon32'),
|
||
})
|
||
|
||
const favicon48Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48', 'favicon48'),
|
||
})
|
||
|
||
const favicon64Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64', 'favicon64'),
|
||
})
|
||
|
||
const favicon128Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128', 'favicon128'),
|
||
})
|
||
|
||
const favicon256Dropzone = useDropzone({
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/x-icon': ['.ico'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256', 'favicon256'),
|
||
})
|
||
|
||
const darkLogoDropzone = useDropzone({
|
||
accept: {
|
||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleLogoUpload(files[0], 'darkLogo'),
|
||
})
|
||
|
||
const lightLogoDropzone = useDropzone({
|
||
accept: {
|
||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
||
},
|
||
maxFiles: 1,
|
||
onDrop: (files) => handleLogoUpload(files[0], 'lightLogo'),
|
||
})
|
||
|
||
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 userColumns = useMemo(
|
||
() => [
|
||
{
|
||
name: t('user.name'),
|
||
selector: (row) => row.name,
|
||
sortable: true,
|
||
},
|
||
{
|
||
name: t('user.email'),
|
||
selector: (row) => row.email,
|
||
sortable: true,
|
||
},
|
||
{
|
||
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>
|
||
<Button
|
||
variant="dark"
|
||
title={t('user.edit')}
|
||
onClick={() => console.log('edit user', row)}
|
||
>
|
||
<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]
|
||
)
|
||
const userTableStyles = useMemo(
|
||
() => ({
|
||
table: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? 'transparent' : '#ffffff',
|
||
},
|
||
},
|
||
headRow: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#f7f2ea',
|
||
borderBottomColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
|
||
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
|
||
fontWeight: 600,
|
||
},
|
||
},
|
||
rows: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : '#ffffff',
|
||
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
|
||
},
|
||
stripedStyle: {
|
||
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#f7f2ea',
|
||
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
|
||
},
|
||
highlightOnHoverStyle: {
|
||
backgroundColor: themeMode === 'dark'
|
||
? 'transparent'
|
||
: 'rgba(0, 0, 0, 0.04)',
|
||
},
|
||
},
|
||
pagination: {
|
||
style: {
|
||
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#ffffff',
|
||
color: themeMode === 'dark' ? '#cfd6df' : '#5b6678',
|
||
borderTopColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
|
||
},
|
||
},
|
||
}),
|
||
[themeMode]
|
||
)
|
||
|
||
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
|
||
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
|
||
const current = Math.min(usersPage, totalPages)
|
||
|
||
const pages = []
|
||
for (let page = Math.max(1, current - 2); page <= Math.min(totalPages, current + 2); page += 1) {
|
||
pages.push(page)
|
||
}
|
||
|
||
const goTo = (page) => {
|
||
if (page < 1 || page > totalPages) return
|
||
onChangePage(page, rowCount)
|
||
}
|
||
|
||
return (
|
||
<div className="bb-pagination">
|
||
<div className="bb-pagination-range">
|
||
{t('table.rows_per_page')} {rowsPerPage}{' '}
|
||
<span className="bb-muted">
|
||
{((current - 1) * rowsPerPage) + 1}-{Math.min(current * rowsPerPage, rowCount)} {t('table.range_separator')} {rowCount}
|
||
</span>
|
||
</div>
|
||
<div className="bb-pagination-actions">
|
||
<button type="button" onClick={() => goTo(1)} disabled={current === 1}>
|
||
«
|
||
</button>
|
||
<button type="button" onClick={() => goTo(current - 1)} disabled={current === 1}>
|
||
‹
|
||
</button>
|
||
{pages.map((page) => (
|
||
<button
|
||
key={page}
|
||
type="button"
|
||
className={page === current ? 'is-active' : ''}
|
||
onClick={() => goTo(page)}
|
||
>
|
||
{page}
|
||
</button>
|
||
))}
|
||
<button type="button" onClick={() => goTo(current + 1)} disabled={current === totalPages}>
|
||
›
|
||
</button>
|
||
<button type="button" onClick={() => goTo(totalPages)} disabled={current === totalPages}>
|
||
»
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
const [collapsed, setCollapsed] = useState(() => new Set())
|
||
const hasInitializedCollapse = useRef(false)
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
description: '',
|
||
type: 'category',
|
||
parentId: '',
|
||
})
|
||
|
||
const refreshForums = async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const data = await listAllForums()
|
||
setForums(data.filter((forum) => !forum.deleted_at))
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (isAdmin) {
|
||
refreshForums()
|
||
}
|
||
}, [isAdmin])
|
||
|
||
useEffect(() => {
|
||
if (!hasInitializedCollapse.current && forums.length > 0) {
|
||
const ids = forums
|
||
.filter((forum) => forum.type === 'category')
|
||
.map((forum) => String(forum.id))
|
||
setCollapsed(new Set(ids))
|
||
hasInitializedCollapse.current = true
|
||
}
|
||
}, [forums])
|
||
|
||
const refreshUsers = async () => {
|
||
setUsersLoading(true)
|
||
setUsersError('')
|
||
try {
|
||
const data = await listUsers()
|
||
setUsers(data)
|
||
} catch (err) {
|
||
setUsersError(err.message)
|
||
} finally {
|
||
setUsersLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (isAdmin) {
|
||
refreshUsers()
|
||
}
|
||
}, [isAdmin])
|
||
|
||
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 || t('forum.no_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>
|
||
<ButtonGroup size="sm" className="bb-action-group">
|
||
{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>
|
||
{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 className="py-5">
|
||
<h2 className="mb-3">{t('acp.title')}</h2>
|
||
<p className="bb-muted">{t('acp.no_access')}</p>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Container fluid className="bb-acp py-4">
|
||
<h2 className="mb-4">{t('acp.title')}</h2>
|
||
<Tabs defaultActiveKey="general" className="mb-3">
|
||
<Tab eventKey="general" title={t('acp.general')}>
|
||
<p className="bb-muted">{t('acp.general_hint')}</p>
|
||
{generalError && <p className="text-danger">{generalError}</p>}
|
||
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
|
||
<Row className="g-3">
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.forum_name')}</Form.Label>
|
||
<Form.Control
|
||
type="text"
|
||
value={generalSettings.forumName}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({ ...prev, forumName: 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.showHeaderName}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
showHeaderName: event.target.checked,
|
||
}))
|
||
}
|
||
/>
|
||
</Form.Group>
|
||
</Col>
|
||
<Col lg={6}>
|
||
<Form.Group>
|
||
<Form.Label>{t('acp.default_theme')}</Form.Label>
|
||
<Form.Select
|
||
value={generalSettings.defaultTheme}
|
||
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.darkAccent}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
darkAccent: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="#f29b3f"
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={generalSettings.darkAccent || '#f29b3f'}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
darkAccent: 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.lightAccent}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
lightAccent: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="#f29b3f"
|
||
/>
|
||
<Form.Control
|
||
type="color"
|
||
value={generalSettings.lightAccent || '#f29b3f'}
|
||
onChange={(event) =>
|
||
setGeneralSettings((prev) => ({
|
||
...prev,
|
||
lightAccent: 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.darkLogo ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.darkLogo} 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.lightLogo ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.lightLogo} 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.faviconIco ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.faviconIco} 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.favicon16 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon16} 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.favicon32 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon32} 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.favicon48 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon48} 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.favicon64 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon64} 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.favicon128 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon128} 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.favicon256 ? (
|
||
<div className="bb-dropzone-preview">
|
||
<img src={generalSettings.favicon256} alt={t('acp.favicon_256')} />
|
||
</div>
|
||
) : (
|
||
<div className="bb-dropzone-placeholder">
|
||
<i className="bi bi-upload" aria-hidden="true" />
|
||
<span>{t('acp.logo_upload')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Form.Group>
|
||
</Col>
|
||
</Row>
|
||
</Accordion.Body>
|
||
</Accordion.Item>
|
||
</Accordion>
|
||
</Col>
|
||
<Col xs={12} className="d-flex justify-content-end">
|
||
<Button
|
||
type="submit"
|
||
className="bb-accent-button"
|
||
disabled={generalSaving || generalUploading}
|
||
>
|
||
{generalSaving ? t('form.saving') : t('acp.save')}
|
||
</Button>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</Tab>
|
||
<Tab eventKey="forums" title={t('acp.forums')}>
|
||
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
||
{error && <p className="text-danger">{error}</p>}
|
||
<Row className="g-4">
|
||
<Col lg={12}>
|
||
<div className="d-flex align-items-center justify-content-between mb-3 gap-3 flex-wrap">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<h5 className="mb-0">{t('acp.forums_tree')}</h5>
|
||
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleExpandAll}>
|
||
<i className="bi bi-arrows-expand me-1" aria-hidden="true" />
|
||
{t('acp.expand_all')}
|
||
</Button>
|
||
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleCollapseAll}>
|
||
<i className="bi bi-arrows-collapse me-1" aria-hidden="true" />
|
||
{t('acp.collapse_all')}
|
||
</Button>
|
||
</div>
|
||
<div className="d-flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline-dark"
|
||
className={`bb-acp-action ${createType === 'category' ? 'bb-acp-action--active' : ''}`}
|
||
onClick={() => handleStartCreate('category')}
|
||
>
|
||
<i className="bi bi-folder2 me-1" aria-hidden="true" />
|
||
{t('acp.new_category')}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline-dark"
|
||
className={`bb-acp-action ${createType === 'forum' ? 'bb-acp-action--active' : ''}`}
|
||
onClick={() => handleStartCreate('forum')}
|
||
>
|
||
<i className="bi bi-chat-left-text me-1" aria-hidden="true" />
|
||
{t('acp.new_forum')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{loading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||
{!loading && forumTree.roots.length === 0 && (
|
||
<p className="bb-muted">{t('acp.forums_empty')}</p>
|
||
)}
|
||
{forumTree.roots.length > 0 && (
|
||
<div className="mt-2">{renderTree(forumTree.roots)}</div>
|
||
)}
|
||
</Col>
|
||
</Row>
|
||
</Tab>
|
||
<Tab eventKey="users" title={t('acp.users')}>
|
||
{usersError && <p className="text-danger">{usersError}</p>}
|
||
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||
{!usersLoading && (
|
||
<DataTable
|
||
columns={userColumns}
|
||
data={users}
|
||
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'),
|
||
}}
|
||
paginationPerPage={usersPerPage}
|
||
onChangePage={(page) => setUsersPage(page)}
|
||
onChangeRowsPerPage={(perPage) => {
|
||
setUsersPerPage(perPage)
|
||
setUsersPage(1)
|
||
}}
|
||
paginationComponent={UsersPagination}
|
||
/>
|
||
)}
|
||
</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>
|
||
</Container>
|
||
)
|
||
}
|