Add ranks and ACP user enhancements

This commit is contained in:
Micha
2026-01-14 00:15:56 +01:00
parent 98094459e3
commit fd29b928d8
21 changed files with 1272 additions and 26 deletions

View File

@@ -8,10 +8,17 @@ import {
deleteForum,
fetchSettings,
listAllForums,
listRanks,
listUsers,
reorderForums,
saveSetting,
saveSettings,
createRank,
deleteRank,
updateUserRank,
updateRank,
updateUser,
uploadRankBadgeImage,
uploadFavicon,
uploadLogo,
updateForum,
@@ -28,10 +35,33 @@ export default function Acp({ isAdmin }) {
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 [rankFormImage, setRankFormImage] = useState(null)
const [rankSaving, setRankSaving] = useState(false)
const [showRankModal, setShowRankModal] = useState(false)
const [rankEdit, setRankEdit] = useState({
id: null,
name: '',
badgeType: 'text',
badgeText: '',
badgeImageUrl: '',
})
const [rankEditImage, setRankEditImage] = useState(null)
const [showUserModal, setShowUserModal] = useState(false)
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
const [userSaving, setUserSaving] = useState(false)
const [generalSaving, setGeneralSaving] = useState(false)
const [generalUploading, setGeneralUploading] = useState(false)
const [generalError, setGeneralError] = useState('')
@@ -341,17 +371,93 @@ export default function Acp({ isAdmin }) {
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 [
{
name: t('user.name'),
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,
},
{
name: t('user.email'),
selector: (row) => row.email,
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: '',
@@ -370,7 +476,16 @@ export default function Acp({ isAdmin }) {
<Button
variant="dark"
title={t('user.edit')}
onClick={() => console.log('edit user', row)}
onClick={() => {
setUserForm({
id: row.id,
name: row.name,
email: row.email,
rankId: row.rank?.id ?? '',
})
setShowUserModal(true)
setUsersError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
@@ -385,8 +500,9 @@ export default function Acp({ isAdmin }) {
</div>
),
},
],
[t]
]
},
[t, ranks, ranksLoading, rankUpdatingId, userSort]
)
const userTableStyles = useMemo(
() => ({
@@ -536,6 +652,57 @@ export default function Acp({ isAdmin }) {
}
}, [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) => {
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,
})
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('')
setRankFormImage(null)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}
const getParentId = (forum) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
@@ -1298,17 +1465,23 @@ export default function Acp({ isAdmin }) {
</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={users}
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'),
@@ -1320,9 +1493,149 @@ export default function Acp({ isAdmin }) {
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="ranks" title={t('acp.ranks')}>
{ranksError && <p className="text-danger">{ranksError}</p>}
<Row className="g-3 align-items-end mb-3">
<Col md={6}>
<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.badge_type')}</Form.Label>
<div className="d-flex gap-3">
<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>
)}
</Form>
</Col>
<Col md="auto">
<Button
type="button"
className="bb-accent-button"
onClick={handleCreateRank}
disabled={rankSaving || !rankFormName.trim()}
>
{rankSaving ? t('form.saving') : t('rank.create')}
</Button>
</Col>
</Row>
{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>{rank.name}</span>
{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>
)}
</div>
<div className="bb-rank-actions">
<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 || '',
})
setRankEditImage(null)
setShowRankModal(true)
setRanksError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
onClick={async () => {
if (!window.confirm(t('rank.delete_confirm'))) return
setRankSaving(true)
setRanksError('')
try {
await deleteRank(rank.id)
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)}
</Tab>
</Tabs>
<Modal show={showModal} onHide={handleReset} centered size="lg">
<Modal.Header closeButton closeVariant="white">
@@ -1410,6 +1723,221 @@ export default function Acp({ isAdmin }) {
</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()
setUserSaving(true)
setUsersError('')
try {
const payload = {
name: userForm.name,
email: userForm.email,
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
}
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)
}
}}
>
<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
/>
</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
/>
</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}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowUserModal(false)}
disabled={userSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
{userSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={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,
})
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.badge_type')}</Form.Label>
<div className="d-flex gap-3">
<Form.Check
type="radio"
id="rank-edit-badge-text"
name="rankEditBadgeType"
label={t('rank.badge_text')}
checked={rankEdit.badgeType === 'text'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
}
/>
<Form.Check
type="radio"
id="rank-edit-badge-image"
name="rankEditBadgeType"
label={t('rank.badge_image')}
checked={rankEdit.badgeType === 'image'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
}
/>
</div>
</Form.Group>
{rankEdit.badgeType === 'text' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_text')}</Form.Label>
<Form.Control
value={rankEdit.badgeText}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
}
placeholder={t('rank.badge_text_placeholder')}
required
/>
</Form.Group>
)}
{rankEdit.badgeType === 'image' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_image')}</Form.Label>
{rankEdit.badgeImageUrl && !rankEditImage && (
<div className="bb-rank-badge-preview">
<img src={rankEdit.badgeImageUrl} alt="" />
</div>
)}
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
/>
</Form.Group>
)}
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRankModal(false)}
disabled={rankSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
{rankSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
</Container>
)
}