Files
speedBB/resources/js/pages/Acp.jsx
tracer c33cde6f04
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
added attchments
2026-01-28 19:34:25 +01:00

3698 lines
174 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
import DataTable, { createTheme } from 'react-data-table-component'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { useAuth } from '../context/AuthContext'
import {
createForum,
deleteForum,
fetchSettings,
listAllForums,
listRanks,
listRoles,
listUsers,
reorderForums,
saveSetting,
saveSettings,
createRank,
deleteRank,
updateUserRank,
updateRank,
updateUser,
createRole,
updateRole,
deleteRole,
uploadRankBadgeImage,
uploadFavicon,
uploadLogo,
updateForum,
listAttachmentGroups,
createAttachmentGroup,
updateAttachmentGroup,
deleteAttachmentGroup,
reorderAttachmentGroups,
listAttachmentExtensions,
createAttachmentExtension,
updateAttachmentExtension,
deleteAttachmentExtension,
} from '../api/client'
export default function Acp({ isAdmin }) {
const { t } = useTranslation()
const { roles: authRoles } = useAuth()
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
const [forums, setForums] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selectedId, setSelectedId] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [overId, setOverId] = useState(null)
const pendingOrder = useRef(null)
const [createType, setCreateType] = useState(null)
const [users, setUsers] = useState([])
const [userSearch, setUserSearch] = useState('')
const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
const [ranks, setRanks] = useState([])
const [ranksLoading, setRanksLoading] = useState(false)
const [ranksError, setRanksError] = useState('')
const [rankUpdatingId, setRankUpdatingId] = useState(null)
const [rankFormName, setRankFormName] = useState('')
const [rankFormType, setRankFormType] = useState('text')
const [rankFormText, setRankFormText] = useState('')
const [rankFormColor, setRankFormColor] = useState('')
const [rankFormImage, setRankFormImage] = useState(null)
const [rankSaving, setRankSaving] = useState(false)
const [showRankCreate, setShowRankCreate] = useState(false)
const [showRankModal, setShowRankModal] = useState(false)
const [rankEdit, setRankEdit] = useState({
id: null,
name: '',
badgeType: 'text',
badgeText: '',
badgeImageUrl: '',
color: '',
})
const [rankEditImage, setRankEditImage] = useState(null)
const [showUserModal, setShowUserModal] = useState(false)
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
const [roleQuery, setRoleQuery] = useState('')
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
const roleMenuRef = useRef(null)
const [roles, setRoles] = useState([])
const [rolesLoading, setRolesLoading] = useState(false)
const [rolesError, setRolesError] = useState('')
const [roleFormName, setRoleFormName] = useState('')
const [roleFormColor, setRoleFormColor] = useState('')
const [roleSaving, setRoleSaving] = useState(false)
const [showRoleCreate, setShowRoleCreate] = useState(false)
const [showRoleModal, setShowRoleModal] = useState(false)
const [roleEdit, setRoleEdit] = useState({
id: null,
name: '',
originalName: '',
color: '',
isCore: false,
})
const [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 [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)
} catch (err) {
if (active) setGeneralError(err.message)
}
}
loadSettings()
return () => {
active = false
}
}, [isAdmin])
const handleGeneralSave = async (event) => {
event.preventDefault()
setGeneralSaving(true)
setGeneralError('')
try {
await saveSettings(
Object.entries(generalSettings).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value.trim() : String(value ?? ''),
}))
)
const detail = Object.entries(generalSettings).reduce((acc, [key, value]) => {
const mappedKey = settingsDetailMap[key]
if (!mappedKey) return acc
if (key === 'show_header_name') {
acc[mappedKey] = value !== 'false'
return acc
}
acc[mappedKey] = typeof value === 'string' ? value.trim() : String(value ?? '')
return acc
}, {})
window.dispatchEvent(new CustomEvent('speedbb-settings-updated', { detail }))
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralSaving(false)
}
}
const handleDefaultThemeChange = async (value) => {
const previous = generalSettings.default_theme
setGeneralSettings((prev) => ({ ...prev, default_theme: value }))
setGeneralError('')
try {
await saveSetting('default_theme', value)
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { defaultTheme: value },
})
)
} catch (err) {
setGeneralSettings((prev) => ({ ...prev, default_theme: previous }))
setGeneralError(err.message)
}
}
const handleLogoUpload = async (file, settingKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadLogo(file)
const url = result?.url || ''
setGeneralSettings((prev) => ({ ...prev, [settingKey]: url }))
if (url) {
await saveSetting(settingKey, url)
const mappedKey = settingsDetailMap[settingKey]
if (mappedKey) {
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { [mappedKey]: url },
})
)
}
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const handleFaviconUpload = async (file, settingKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadFavicon(file)
const url = result?.url || ''
setGeneralSettings((prev) => ({ ...prev, [settingKey]: url }))
if (url) {
await saveSetting(settingKey, url)
const mappedKey = settingsDetailMap[settingKey]
if (mappedKey) {
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { [mappedKey]: url },
})
)
}
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const faviconIcoDropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico'),
})
const favicon16Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16'),
})
const favicon32Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32'),
})
const favicon48Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48'),
})
const favicon64Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64'),
})
const favicon128Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128'),
})
const favicon256Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256'),
})
const darkLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'logo_dark'),
})
const lightLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'logo_light'),
})
useEffect(() => {
const observer = new MutationObserver(() => {
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-bs-theme'],
})
return () => observer.disconnect()
}, [])
const filteredUsers = useMemo(() => {
const term = userSearch.trim().toLowerCase()
if (!term) return users
return users.filter((user) =>
[user.name, user.email, user.rank?.name]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(term))
)
}, [users, userSearch])
const userColumns = useMemo(
() => {
const iconFor = (id) => {
if (userSort.columnId !== id) {
return 'bi-arrow-down-up'
}
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
}
return [
{
id: 'name',
name: (
<span className="bb-sort-label">
{t('user.name')}
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.name,
sortable: true,
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
sensitivity: 'base',
}),
},
{
id: 'email',
name: (
<span className="bb-sort-label">
{t('user.email')}
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.email,
sortable: true,
},
{
id: 'rank',
name: (
<span className="bb-sort-label">
{t('user.rank')}
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
</span>
),
width: '220px',
sortable: true,
sortFunction: (a, b) =>
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
cell: (row) => (
<Form.Select
size="sm"
value={row.rank?.id ?? ''}
disabled={ranksLoading || rankUpdatingId === row.id}
onChange={async (event) => {
const nextRankId = event.target.value ? Number(event.target.value) : null
setRankUpdatingId(row.id)
try {
const updated = await updateUserRank(row.id, nextRankId)
setUsers((prev) =>
prev.map((user) =>
user.id === row.id ? { ...user, rank: updated.rank } : user
)
)
} catch (err) {
setUsersError(err.message)
} finally {
setRankUpdatingId(null)
}
}}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
),
},
{
name: '',
width: '180px',
right: true,
cell: (row) => (
<div className="bb-user-actions">
<ButtonGroup className="bb-action-group">
<Button
variant="dark"
title={t('user.impersonate')}
onClick={() => console.log('impersonate', row)}
>
<i className="bi bi-person-badge" aria-hidden="true" />
</Button>
{(() => {
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
return (
<Button
variant="dark"
title={editLocked ? t('user.founder_locked') : t('user.edit')}
aria-disabled={editLocked}
className={editLocked ? 'bb-btn-disabled' : undefined}
onClick={() => {
if (editLocked) return
setUserForm({
id: row.id,
name: row.name,
email: row.email,
rankId: row.rank?.id ?? '',
roles: row.roles || [],
})
setShowUserModal(true)
setUsersError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
)
})()}
<Button
variant="dark"
title={t('user.delete')}
onClick={() => console.log('delete user', row)}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
),
},
]
},
[t, ranks, ranksLoading, rankUpdatingId, userSort]
)
const userTableStyles = useMemo(
() => ({
table: {
style: {
backgroundColor: themeMode === 'dark' ? 'transparent' : '#ffffff',
},
},
headRow: {
style: {
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#f7f2ea',
borderBottomColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
fontWeight: 600,
},
},
rows: {
style: {
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : '#ffffff',
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
},
stripedStyle: {
backgroundColor: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#f7f2ea',
color: themeMode === 'dark' ? '#e6e8eb' : '#0e121b',
},
highlightOnHoverStyle: {
backgroundColor: themeMode === 'dark'
? 'transparent'
: 'rgba(0, 0, 0, 0.04)',
},
},
pagination: {
style: {
backgroundColor: themeMode === 'dark' ? '#1a1f29' : '#ffffff',
color: themeMode === 'dark' ? '#cfd6df' : '#5b6678',
borderTopColor: themeMode === 'dark' ? '#2a2f3a' : '#e0d7c7',
},
},
}),
[themeMode]
)
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
const current = Math.min(usersPage, totalPages)
const pages = []
for (let page = Math.max(1, current - 2); page <= Math.min(totalPages, current + 2); page += 1) {
pages.push(page)
}
const goTo = (page) => {
if (page < 1 || page > totalPages) return
onChangePage(page, rowCount)
}
return (
<div className="bb-pagination">
<div className="bb-pagination-range">
{t('table.rows_per_page')} {rowsPerPage}{' '}
<span className="bb-muted">
{((current - 1) * rowsPerPage) + 1}-{Math.min(current * rowsPerPage, rowCount)} {t('table.range_separator')} {rowCount}
</span>
</div>
<div className="bb-pagination-actions">
<button type="button" onClick={() => goTo(1)} disabled={current === 1}>
«
</button>
<button type="button" onClick={() => goTo(current - 1)} disabled={current === 1}>
</button>
{pages.map((page) => (
<button
key={page}
type="button"
className={page === current ? 'is-active' : ''}
onClick={() => goTo(page)}
>
{page}
</button>
))}
<button type="button" onClick={() => goTo(current + 1)} disabled={current === totalPages}>
</button>
<button type="button" onClick={() => goTo(totalPages)} disabled={current === totalPages}>
»
</button>
</div>
</div>
)
}
const [collapsed, setCollapsed] = useState(() => new Set())
const hasInitializedCollapse = useRef(false)
const [showModal, setShowModal] = useState(false)
const [form, setForm] = useState({
name: '',
description: '',
type: 'category',
parentId: '',
})
const refreshForums = async () => {
setLoading(true)
setError('')
try {
const data = await listAllForums()
setForums(data.filter((forum) => !forum.deleted_at))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshForums()
}
}, [isAdmin])
useEffect(() => {
if (!hasInitializedCollapse.current && forums.length > 0) {
const ids = forums
.filter((forum) => forum.type === 'category')
.map((forum) => String(forum.id))
setCollapsed(new Set(ids))
hasInitializedCollapse.current = true
}
}, [forums])
const refreshUsers = async () => {
setUsersLoading(true)
setUsersError('')
try {
const data = await listUsers()
setUsers(data)
} catch (err) {
setUsersError(err.message)
} finally {
setUsersLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshUsers()
}
}, [isAdmin])
useEffect(() => {
if (!roleMenuOpen) return
const handleClick = (event) => {
if (!roleMenuRef.current) return
if (!roleMenuRef.current.contains(event.target)) {
setRoleMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [roleMenuOpen])
useEffect(() => {
if (!isAdmin) return
let active = true
const loadRoles = async () => {
setRolesLoading(true)
setRolesError('')
try {
const data = await listRoles()
if (active) setRoles(data)
} catch (err) {
if (active) setRolesError(err.message)
} finally {
if (active) setRolesLoading(false)
}
}
loadRoles()
return () => {
active = false
}
}, [isAdmin])
const refreshRanks = async () => {
setRanksLoading(true)
setRanksError('')
try {
const data = await listRanks()
setRanks(data)
} catch (err) {
setRanksError(err.message)
} finally {
setRanksLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshRanks()
}
}, [isAdmin])
const handleCreateRank = async (event) => {
if (event?.preventDefault) {
event.preventDefault()
}
if (!rankFormName.trim()) return
if (rankFormType === 'image' && !rankFormImage) {
setRanksError(t('rank.badge_image_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const created = await createRank({
name: rankFormName.trim(),
badge_type: rankFormType,
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
color: rankFormColor.trim() || null,
})
let next = created
if (rankFormType === 'image' && rankFormImage) {
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
next = { ...created, ...updated }
}
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
setRankFormName('')
setRankFormType('text')
setRankFormText('')
setRankFormColor('')
setRankFormImage(null)
setShowRankCreate(false)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}
const isCoreRole = (name) => name === 'ROLE_ADMIN' || name === 'ROLE_USER' || name === 'ROLE_FOUNDER'
const formatRoleLabel = (name) => {
if (!name) return ''
const withoutPrefix = name.startsWith('ROLE_') ? name.slice(5) : name
return withoutPrefix
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
const filteredRoles = useMemo(() => {
const query = roleQuery.trim().toLowerCase()
if (!query) return roles
return roles.filter((role) =>
formatRoleLabel(role.name).toLowerCase().includes(query)
)
}, [roles, roleQuery])
const 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 createdGroups = []
const createdExtensions = []
for (const group of attachmentDefaultSeed) {
const parent = await createAttachmentGroup({
name: group.name,
max_size_kb: group.max_size_kb,
is_active: true,
})
createdGroups.push(parent)
for (const child of group.children || []) {
const createdChild = await createAttachmentGroup({
name: child.name,
parent_id: parent.id,
max_size_kb: child.max_size_kb,
is_active: true,
})
createdGroups.push(createdChild)
for (const ext of child.extensions || []) {
const createdExt = await createAttachmentExtension({
extension: ext.ext,
attachment_group_id: createdChild.id,
allowed_mimes: ext.mimes,
})
createdExtensions.push(createdExt)
}
}
}
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 }}
>
<div className="bb-attachment-extension-header tr-header">
<span>{t('attachment.extension')}</span>
<span>{t('attachment.allowed_mimes')}</span>
<span>{t('attachment.extension_group')}</span>
<span>{t('attachment.actions')}</span>
</div>
{groupExtensions.map((extension) => (
<div key={extension.id} className="bb-attachment-extension-row">
<span className="bb-attachment-extension-name">
{extension.extension}
</span>
<span className="bb-attachment-extension-meta">
{(extension.allowed_mimes || []).join(', ')}
</span>
<span className="bb-attachment-extension-meta">
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|| t('attachment.extension_unassigned')}
</span>
<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>
</div>
))}
</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')}>
<p className="bb-muted">{t('acp.general_hint')}</p>
{generalError && <p className="text-danger">{generalError}</p>}
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
<Row className="g-3">
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.forum_name')}</Form.Label>
<Form.Control
type="text"
value={generalSettings.forum_name}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forum_name: event.target.value }))
}
/>
</Form.Group>
<Form.Group className="mt-2">
<Form.Check
type="checkbox"
id="acp-show-header-name"
label={t('acp.show_header_name')}
checked={generalSettings.show_header_name !== 'false'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
show_header_name: event.target.checked ? 'true' : 'false',
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.default_theme}
onChange={(event) => handleDefaultThemeChange(event.target.value)}
>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_dark')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.accent_color_dark}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_dark || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_light')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.accent_color_light}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_light || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_dark')}</Form.Label>
<div
{...darkLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...darkLogoDropzone.getInputProps()} />
{generalSettings.logo_dark ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_dark} alt={t('acp.logo_dark')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_light')}</Form.Label>
<div
{...lightLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...lightLogoDropzone.getInputProps()} />
{generalSettings.logo_light ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_light} alt={t('acp.logo_light')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col xs={12}>
<Accordion className="bb-acp-accordion">
<Accordion.Item eventKey="favicons">
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
<Accordion.Body>
<Row className="g-3">
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...faviconIcoDropzone.getInputProps()} />
{generalSettings.favicon_ico ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_ico} alt={t('acp.favicon_ico')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_16')}</Form.Label>
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon16Dropzone.getInputProps()} />
{generalSettings.favicon_16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_16} alt={t('acp.favicon_16')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_32')}</Form.Label>
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon32Dropzone.getInputProps()} />
{generalSettings.favicon_32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_32} alt={t('acp.favicon_32')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_48')}</Form.Label>
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon48Dropzone.getInputProps()} />
{generalSettings.favicon_48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_48} alt={t('acp.favicon_48')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_64')}</Form.Label>
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon64Dropzone.getInputProps()} />
{generalSettings.favicon_64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_64} alt={t('acp.favicon_64')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_128')}</Form.Label>
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon128Dropzone.getInputProps()} />
{generalSettings.favicon_128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_128} alt={t('acp.favicon_128')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_256')}</Form.Label>
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon256Dropzone.getInputProps()} />
{generalSettings.favicon_256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_256} alt={t('acp.favicon_256')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
</Row>
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Col>
<Col xs={12} className="d-flex justify-content-end">
<Button
type="submit"
className="bb-accent-button"
disabled={generalSaving || generalUploading}
>
{generalSaving ? t('form.saving') : t('acp.save')}
</Button>
</Col>
</Row>
</Form>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>
{error && <p className="text-danger">{error}</p>}
<Row className="g-4">
<Col lg={12}>
<div className="d-flex align-items-center justify-content-between mb-3 gap-3 flex-wrap">
<div className="d-flex align-items-center gap-2">
<h5 className="mb-0">{t('acp.forums_tree')}</h5>
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleExpandAll}>
<i className="bi bi-arrows-expand me-1" aria-hidden="true" />
{t('acp.expand_all')}
</Button>
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleCollapseAll}>
<i className="bi bi-arrows-collapse me-1" aria-hidden="true" />
{t('acp.collapse_all')}
</Button>
</div>
<div className="d-flex gap-2">
<Button
size="sm"
variant="outline-dark"
className={`bb-acp-action ${createType === 'category' ? 'bb-acp-action--active' : ''}`}
onClick={() => handleStartCreate('category')}
>
<i className="bi bi-folder2 me-1" aria-hidden="true" />
{t('acp.new_category')}
</Button>
<Button
size="sm"
variant="outline-dark"
className={`bb-acp-action ${createType === 'forum' ? 'bb-acp-action--active' : ''}`}
onClick={() => handleStartCreate('forum')}
>
<i className="bi bi-chat-left-text me-1" aria-hidden="true" />
{t('acp.new_forum')}
</Button>
</div>
</div>
{loading && <p className="bb-muted">{t('acp.loading')}</p>}
{!loading && forumTree.roots.length === 0 && (
<p className="bb-muted">{t('acp.forums_empty')}</p>
)}
{forumTree.roots.length > 0 && (
<div className="mt-2">{renderTree(forumTree.roots)}</div>
)}
</Col>
</Row>
</Tab>
<Tab eventKey="users" title={t('acp.users')}>
{usersError && <p className="text-danger">{usersError}</p>}
{ranksError && <p className="text-danger">{ranksError}</p>}
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!usersLoading && (
<DataTable
columns={userColumns}
data={filteredUsers}
pagination
striped
highlightOnHover={themeMode !== 'dark'}
dense
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
customStyles={userTableStyles}
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
defaultSortFieldId="name"
onSort={(column, direction) => {
setUserSort({ columnId: column.id, direction })
}}
paginationComponentOptions={{
rowsPerPageText: t('table.rows_per_page'),
rangeSeparatorText: t('table.range_separator'),
}}
paginationPerPage={usersPerPage}
onChangePage={(page) => setUsersPage(page)}
onChangeRowsPerPage={(perPage) => {
setUsersPerPage(perPage)
setUsersPage(1)
}}
paginationComponent={UsersPagination}
subHeader
subHeaderComponent={
<Form.Control
className="bb-user-search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder={t('user.search')}
/>
}
/>
)}
</Tab>
<Tab eventKey="groups" title={t('acp.groups')}>
{rolesError && <p className="text-danger">{rolesError}</p>}
<div className="d-flex justify-content-end mb-3">
<Button
type="button"
className="bb-accent-button"
onClick={() => setShowRoleCreate(true)}
>
{t('group.create')}
</Button>
</div>
{rolesLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!rolesLoading && roles.length === 0 && (
<p className="bb-muted">{t('group.empty')}</p>
)}
{!rolesLoading && roles.length > 0 && (
<div className="bb-rank-list">
{roles.map((role) => {
const coreRole = isCoreRole(role.name)
return (
<div key={role.id} className="bb-rank-row">
<div className="bb-rank-main">
<span className="d-flex align-items-center gap-2">
{role.color && (
<span
className="bb-rank-color"
style={{ backgroundColor: role.color }}
aria-hidden="true"
/>
)}
<span>{formatRoleLabel(role.name)}</span>
</span>
</div>
<div className="bb-rank-actions">
<Button
size="sm"
variant="dark"
title={coreRole ? t('group.core_locked') : t('group.edit')}
onClick={() => {
setRoleEdit({
id: role.id,
name: formatRoleLabel(role.name),
originalName: role.name,
color: role.color || '',
isCore: coreRole,
})
setShowRoleModal(true)
setRolesError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
disabled={coreRole}
title={coreRole ? t('group.core_locked') : t('group.delete')}
onClick={async () => {
if (coreRole) return
if (!window.confirm(t('group.delete_confirm'))) return
setRoleSaving(true)
setRolesError('')
try {
await deleteRole(role.id)
setRoles((prev) =>
prev.filter((item) => item.id !== role.id)
)
setUsers((prev) =>
prev.map((user) => ({
...user,
roles: (user.roles || []).filter(
(name) => name !== role.name
),
}))
)
} catch (err) {
setRolesError(err.message)
} finally {
setRoleSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
)
})}
</div>
)}
</Tab>
<Tab eventKey="ranks" title={t('acp.ranks')}>
{ranksError && <p className="text-danger">{ranksError}</p>}
<div className="d-flex justify-content-end mb-3">
<Button
type="button"
className="bb-accent-button"
onClick={() => setShowRankCreate(true)}
>
{t('rank.create')}
</Button>
</div>
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!ranksLoading && ranks.length === 0 && (
<p className="bb-muted">{t('rank.empty')}</p>
)}
{!ranksLoading && ranks.length > 0 && (
<div className="bb-rank-list">
{ranks.map((rank) => (
<div key={rank.id} className="bb-rank-row">
<div className="bb-rank-main">
<span className="d-flex align-items-center gap-2">
{rank.color && (
<span
className="bb-rank-color"
style={{ backgroundColor: rank.color }}
aria-hidden="true"
/>
)}
<span>{rank.name}</span>
</span>
</div>
<div className="bb-rank-actions">
{rank.badge_type === 'image' && rank.badge_image_url && (
<img src={rank.badge_image_url} alt="" />
)}
{rank.badge_type !== 'image' && rank.badge_text && (
<span className="bb-rank-badge">{rank.badge_text}</span>
)}
<Button
size="sm"
variant="dark"
onClick={() => {
setRankEdit({
id: rank.id,
name: rank.name,
badgeType: rank.badge_type || 'text',
badgeText: rank.badge_text || '',
badgeImageUrl: rank.badge_image_url || '',
color: rank.color || '',
})
setRankEditImage(null)
setShowRankModal(true)
setRanksError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
onClick={async () => {
if (!window.confirm(t('rank.delete_confirm'))) return
setRankSaving(true)
setRanksError('')
try {
await deleteRank(rank.id)
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)}
</Tab>
<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.groups_title')}</h5>
<div className="d-flex align-items-center gap-2">
<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"
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>
</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={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>
)
}