Add ranks and ACP user enhancements
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ export default function ThreadView() {
|
||||
}
|
||||
|
||||
const replyCount = posts.length
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
const allPosts = useMemo(() => {
|
||||
if (!thread) return posts
|
||||
const rootPost = {
|
||||
@@ -53,6 +62,12 @@ export default function ThreadView() {
|
||||
created_at: thread.created_at,
|
||||
user_name: thread.user_name,
|
||||
user_avatar_url: thread.user_avatar_url,
|
||||
user_posts_count: thread.user_posts_count,
|
||||
user_created_at: thread.user_created_at,
|
||||
user_rank_name: thread.user_rank_name,
|
||||
user_rank_badge_type: thread.user_rank_badge_type,
|
||||
user_rank_badge_text: thread.user_rank_badge_text,
|
||||
user_rank_badge_url: thread.user_rank_badge_url,
|
||||
isRoot: true,
|
||||
}
|
||||
return [rootPost, ...posts]
|
||||
@@ -107,11 +122,17 @@ export default function ThreadView() {
|
||||
</div>
|
||||
|
||||
<div className="bb-posts">
|
||||
{allPosts.map((post) => {
|
||||
{allPosts.map((post, index) => {
|
||||
const authorName = post.author?.username
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
: `${t('thread.reply_prefix')} ${thread.title}`
|
||||
: ''
|
||||
const postNumber = index + 1
|
||||
|
||||
return (
|
||||
<article className="bb-post-row" key={post.id}>
|
||||
@@ -124,16 +145,30 @@ export default function ThreadView() {
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-author-name">{authorName}</div>
|
||||
<div className="bb-post-author-role">Operator</div>
|
||||
<div className="bb-post-author-badge">TEAM-RHF</div>
|
||||
<div className="bb-post-author-role">
|
||||
{post.user_rank_name || ''}
|
||||
</div>
|
||||
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
|
||||
<div className="bb-post-author-badge">
|
||||
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
|
||||
<img src={post.user_rank_badge_url} alt="" />
|
||||
) : (
|
||||
<span>{post.user_rank_badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bb-post-author-meta">
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Posts:</span>
|
||||
<span className="bb-post-author-value">63899</span>
|
||||
<span className="bb-post-author-label">{t('thread.posts')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_posts_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Registered:</span>
|
||||
<span className="bb-post-author-value">18.08.2004 18:50:03</span>
|
||||
<span className="bb-post-author-label">{t('thread.registered')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{formatDate(post.user_created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Location:</span>
|
||||
@@ -158,6 +193,11 @@ export default function ThreadView() {
|
||||
<div className="bb-post-content">
|
||||
<div className="bb-post-header">
|
||||
<div className="bb-post-header-meta">
|
||||
{topicLabel && (
|
||||
<span className="bb-post-topic">
|
||||
#{postNumber} {topicLabel}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('thread.by')} {authorName}</span>
|
||||
{post.created_at && (
|
||||
<span>{post.created_at.slice(0, 10)}</span>
|
||||
|
||||
Reference in New Issue
Block a user