feat: add installer, ranks/groups enhancements, and founder protections

This commit is contained in:
Micha
2026-01-18 15:52:53 +01:00
parent 24c16ed0dd
commit 073c81012b
43 changed files with 6176 additions and 4039 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,225 +6,236 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
export default function BoardIndex() {
const [forums, setForums] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState({})
const { t } = useTranslation()
const { token } = useAuth()
const collapsedKey = 'board_index.collapsed_categories'
const storageKey = `speedbb_user_setting_${collapsedKey}`
const saveTimer = useRef(null)
const [forums, setForums] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState({})
const { t } = useTranslation()
const { token } = useAuth()
const collapsedKey = 'board_index.collapsed_categories'
const storageKey = `speedbb_user_setting_${collapsedKey}`
const saveTimer = useRef(null)
useEffect(() => {
listAllForums()
.then(setForums)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
listAllForums()
.then(setForums)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!token) return
let active = true
useEffect(() => {
if (!token) return
let active = true
const cached = localStorage.getItem(storageKey)
if (cached) {
try {
const parsed = JSON.parse(cached)
if (Array.isArray(parsed)) {
const next = {}
parsed.forEach((id) => {
next[String(id)] = true
})
setCollapsed(next)
const cached = localStorage.getItem(storageKey)
if (cached) {
try {
const parsed = JSON.parse(cached)
if (Array.isArray(parsed)) {
const next = {}
parsed.forEach((id) => {
next[String(id)] = true
})
setCollapsed(next)
}
} catch {
localStorage.removeItem(storageKey)
}
}
} catch {
localStorage.removeItem(storageKey)
}
}
fetchUserSetting(collapsedKey)
.then((setting) => {
if (!active) return
const next = {}
if (Array.isArray(setting?.value)) {
setting.value.forEach((id) => {
next[String(id)] = true
})
fetchUserSetting(collapsedKey)
.then((setting) => {
if (!active) return
const next = {}
if (Array.isArray(setting?.value)) {
setting.value.forEach((id) => {
next[String(id)] = true
})
}
setCollapsed(next)
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
})
.catch(() => {})
return () => {
active = false
}
setCollapsed(next)
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
})
.catch(() => {})
}, [token])
return () => {
active = false
}
}, [token])
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))
const getParentId = (forum) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
sortNodes(roots)
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
return roots
}, [forums])
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
})
const renderRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
{node.children?.length > 0 && (
<div className="bb-board-subforums">
{t('forum.children')}:{' '}
{node.children.map((child, index) => (
<span key={child.id}>
<Link to={`/forum/${child.id}`} className="bb-board-subforum-link">
{child.name}
</Link>
{index < node.children.length - 1 ? ', ' : ''}
</span>
))}
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
}, [forums])
const renderRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
{node.children?.length > 0 && (
<div className="bb-board-subforums">
{t('forum.children')}:{' '}
{node.children.map((child, index) => (
<span key={child.id}>
<Link to={`/forum/${child.id}`} className="bb-board-subforum-link">
{child.name}
</Link>
{index < node.children.length - 1 ? ', ' : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link
to={`/profile/${node.last_post_user_id}`}
className="bb-board-last-link"
style={
node.last_post_user_rank_color || node.last_post_user_group_color
? {
'--bb-user-link-color':
node.last_post_user_rank_color || node.last_post_user_group_color,
}
: undefined
}
>
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">
{node.last_post_at.slice(0, 10)}
</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div>
)}
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">
{node.last_post_at.slice(0, 10)}
</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div>
</div>
))
))
return (
<Container fluid className="py-4 bb-portal-shell">
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (
<p className="bb-muted">{t('home.empty')}</p>
)}
{forumTree.length > 0 && (
<div className="bb-board-index">
{forumTree.map((category) => (
<section className="bb-board-section" key={category.id}>
<header className="bb-board-section__header">
<span className="bb-board-section__title">{category.name}</span>
<div className="bb-board-section__controls">
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
<button
type="button"
className="bb-board-toggle"
onClick={() =>
setCollapsed((prev) => {
const next = {
...prev,
[category.id]: !prev[category.id],
}
const collapsedIds = Object.keys(next).filter((key) => next[key])
localStorage.setItem(storageKey, JSON.stringify(collapsedIds))
if (token) {
if (saveTimer.current) {
clearTimeout(saveTimer.current)
}
saveTimer.current = setTimeout(() => {
saveUserSetting(collapsedKey, collapsedIds).catch(() => {})
}, 400)
}
return next
})
}
aria-label={
collapsed[category.id]
? t('forum.expand_category')
: t('forum.collapse_category')
}
>
<i
className={`bi ${
collapsed[category.id] ? 'bi-plus-square' : 'bi-dash-square'
}`}
aria-hidden="true"
/>
</button>
return (
<Container fluid className="py-4 bb-portal-shell">
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (
<p className="bb-muted">{t('home.empty')}</p>
)}
{forumTree.length > 0 && (
<div className="bb-board-index">
{forumTree.map((category) => (
<section className="bb-board-section" key={category.id}>
<header className="bb-board-section__header">
<span className="bb-board-section__title">{category.name}</span>
<div className="bb-board-section__controls">
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
<button
type="button"
className="bb-board-toggle"
onClick={() =>
setCollapsed((prev) => {
const next = {
...prev,
[category.id]: !prev[category.id],
}
const collapsedIds = Object.keys(next).filter((key) => next[key])
localStorage.setItem(storageKey, JSON.stringify(collapsedIds))
if (token) {
if (saveTimer.current) {
clearTimeout(saveTimer.current)
}
saveTimer.current = setTimeout(() => {
saveUserSetting(collapsedKey, collapsedIds).catch(() => {})
}, 400)
}
return next
})
}
aria-label={
collapsed[category.id]
? t('forum.expand_category')
: t('forum.collapse_category')
}
>
<i
className={`bi ${
collapsed[category.id] ? 'bi-plus-square' : 'bi-dash-square'
}`}
aria-hidden="true"
/>
</button>
</div>
</header>
{!collapsed[category.id] && (
<div className="bb-board-section__body">
{category.children?.length > 0 ? (
renderRows(category.children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
)}
</section>
))}
</div>
</header>
{!collapsed[category.id] && (
<div className="bb-board-section__body">
{category.children?.length > 0 ? (
renderRows(category.children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
)}
</section>
))}
</div>
)}
</Container>
)
)}
</Container>
)
}

View File

@@ -7,241 +7,252 @@ import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function ForumView() {
const { id } = useParams()
const { token } = useAuth()
const [forum, setForum] = useState(null)
const [children, setChildren] = useState([])
const [threads, setThreads] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const { t } = useTranslation()
const { id } = useParams()
const { token } = useAuth()
const [forum, setForum] = useState(null)
const [children, setChildren] = useState([])
const [threads, setThreads] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const { t } = useTranslation()
const renderChildRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div>
</div>
))
useEffect(() => {
let active = true
const loadData = async () => {
setLoading(true)
setError('')
try {
const forumData = await getForum(id)
if (!active) return
setForum(forumData)
const childData = await listForumsByParent(id)
if (!active) return
setChildren(childData)
if (forumData.type === 'forum') {
const threadData = await listThreadsByForum(id)
if (!active) return
setThreads(threadData)
} else {
setThreads([])
}
} catch (err) {
if (active) setError(err.message)
} finally {
if (active) setLoading(false)
}
}
loadData()
return () => {
active = false
}
}, [id])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createThread({ title, body, forumId: id })
setTitle('')
setBody('')
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<Row className="g-4">
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
const renderChildRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
</div>
</div>
</section>
</div>
)}
{forum.type === 'forum' && (
<>
<div className="bb-topic-toolbar mt-4 mb-2">
<div className="bb-topic-toolbar__left">
<Button
variant="dark"
className="bb-topic-action bb-accent-button"
onClick={() => setShowModal(true)}
disabled={!token || saving}
>
<i className="bi bi-pencil me-2" aria-hidden="true" />
{t('forum.start_thread')}
</Button>
</div>
<div className="bb-topic-toolbar__right">
<span className="bb-topic-count">
{threads.length} {t('forum.threads').toLowerCase()}
</span>
<div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled>
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
</Button>
</div>
</div>
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{threads.length === 0 && (
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link
to={`/profile/${node.last_post_user_id}`}
className="bb-board-last-link"
style={
node.last_post_user_rank_color || node.last_post_user_group_color
? {
'--bb-user-link-color':
node.last_post_user_rank_color || node.last_post_user_group_color,
}
: undefined
}
>
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
{threads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={forum?.name || t('portal.unknown_forum')}
forumId={forum?.id}
showForum={false}
/>
))}
</div>
</div>
</div>
))
useEffect(() => {
let active = true
const loadData = async () => {
setLoading(true)
setError('')
try {
const forumData = await getForum(id)
if (!active) return
setForum(forumData)
const childData = await listForumsByParent(id)
if (!active) return
setChildren(childData)
if (forumData.type === 'forum') {
const threadData = await listThreadsByForum(id)
if (!active) return
setThreads(threadData)
} else {
setThreads([])
}
} catch (err) {
if (active) setError(err.message)
} finally {
if (active) setLoading(false)
}
}
loadData()
return () => {
active = false
}
}, [id])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createThread({ title, body, forumId: id })
setTitle('')
setBody('')
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<Row className="g-4">
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
</section>
</div>
)}
{forum.type === 'forum' && (
<>
<div className="bb-topic-toolbar mt-4 mb-2">
<div className="bb-topic-toolbar__left">
<Button
variant="dark"
className="bb-topic-action bb-accent-button"
onClick={() => setShowModal(true)}
disabled={!token || saving}
>
<i className="bi bi-pencil me-2" aria-hidden="true" />
{t('forum.start_thread')}
</Button>
</div>
<div className="bb-topic-toolbar__right">
<span className="bb-topic-count">
{threads.length} {t('forum.threads').toLowerCase()}
</span>
<div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled>
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
</Button>
</div>
</div>
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{threads.length === 0 && (
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
)}
{threads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={forum?.name || t('portal.unknown_forum')}
forumId={forum?.id}
showForum={false}
/>
))}
</div>
</>
)}
</Col>
</Row>
</>
)}
</Col>
</Row>
</>
)}
{forum?.type === 'forum' && (
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
placeholder={t('form.thread_title_placeholder')}
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
)}
</Container>
)
)}
{forum?.type === 'forum' && (
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
placeholder={t('form.thread_title_placeholder')}
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
)}
</Container>
)
}

View File

@@ -7,248 +7,256 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
export default function Home() {
const [forums, setForums] = useState([])
const [threads, setThreads] = useState([])
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
const [error, setError] = useState('')
const [loadingForums, setLoadingForums] = useState(true)
const [loadingThreads, setLoadingThreads] = useState(true)
const [loadingStats, setLoadingStats] = useState(true)
const [profile, setProfile] = useState(null)
const { token, roles, email } = useAuth()
const { t } = useTranslation()
const [forums, setForums] = useState([])
const [threads, setThreads] = useState([])
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
const [error, setError] = useState('')
const [loadingForums, setLoadingForums] = useState(true)
const [loadingThreads, setLoadingThreads] = useState(true)
const [loadingStats, setLoadingStats] = useState(true)
const [profile, setProfile] = useState(null)
const { token, roles, email } = useAuth()
const { t } = useTranslation()
useEffect(() => {
let active = true
setLoadingForums(true)
setLoadingThreads(true)
setLoadingStats(true)
setError('')
useEffect(() => {
let active = true
setLoadingForums(true)
setLoadingThreads(true)
setLoadingStats(true)
setError('')
fetchPortalSummary()
.then((data) => {
if (!active) return
setForums(data?.forums || [])
setThreads(data?.threads || [])
setStats({
threads: data?.stats?.threads ?? 0,
posts: data?.stats?.posts ?? 0,
users: data?.stats?.users ?? 0,
fetchPortalSummary()
.then((data) => {
if (!active) return
setForums(data?.forums || [])
setThreads(data?.threads || [])
setStats({
threads: data?.stats?.threads ?? 0,
posts: data?.stats?.posts ?? 0,
users: data?.stats?.users ?? 0,
})
setProfile(data?.profile || null)
})
.catch((err) => {
if (!active) return
setError(err.message)
setForums([])
setThreads([])
setStats({ threads: 0, posts: 0, users: 0 })
setProfile(null)
})
.finally(() => {
if (!active) return
setLoadingForums(false)
setLoadingThreads(false)
setLoadingStats(false)
})
return () => {
active = false
}
}, [token])
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: [] })
})
setProfile(data?.profile || null)
})
.catch((err) => {
if (!active) return
setError(err.message)
setForums([])
setThreads([])
setStats({ threads: 0, posts: 0, users: 0 })
setProfile(null)
})
.finally(() => {
if (!active) return
setLoadingForums(false)
setLoadingThreads(false)
setLoadingStats(false)
})
return () => {
active = false
}
}, [token])
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 getParentId = (forum) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
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))
}
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
sortNodes(roots)
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
})
return roots
}, [forums])
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 forumMap = useMemo(() => {
const map = new Map()
forums.forEach((forum) => {
map.set(String(forum.id), forum)
})
return map
}, [forums])
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))
const recentThreads = useMemo(() => {
return [...threads]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 12)
}, [threads])
const roleLabel = useMemo(() => {
if (!roles?.length) return t('portal.user_role_member')
if (roles.includes('ROLE_ADMIN')) return t('portal.user_role_operator')
if (roles.includes('ROLE_MODERATOR')) return t('portal.user_role_moderator')
return t('portal.user_role_member')
}, [roles, t])
const resolveForumName = (thread) => {
if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/')
const id = parts[parts.length - 1]
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
}
sortNodes(roots)
const resolveForumId = (thread) => {
if (!thread?.forum) return null
const parts = thread.forum.split('/')
return parts[parts.length - 1] || null
}
return roots
}, [forums])
const forumMap = useMemo(() => {
const map = new Map()
forums.forEach((forum) => {
map.set(String(forum.id), forum)
})
return map
}, [forums])
const recentThreads = useMemo(() => {
return [...threads]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 12)
}, [threads])
const roleLabel = useMemo(() => {
if (!roles?.length) return t('portal.user_role_member')
if (roles.includes('ROLE_ADMIN')) return t('portal.user_role_operator')
if (roles.includes('ROLE_MODERATOR')) return t('portal.user_role_moderator')
return t('portal.user_role_member')
}, [roles, t])
const resolveForumName = (thread) => {
if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/')
const id = parts[parts.length - 1]
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
}
const resolveForumId = (thread) => {
if (!thread?.forum) return null
const parts = thread.forum.split('/')
return parts[parts.length - 1] || null
}
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
<div
className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
style={{ marginLeft: depth * 16 }}
>
<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'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
{node.name}
</Link>
<div className="bb-muted">{node.description || ''}</div>
</div>
</div>
</div>
{node.children?.length > 0 && (
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
)}
</div>
))
return (
<Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.menu')}</div>
<ul className="bb-portal-list">
<li>{t('portal.menu_news')}</li>
<li>{t('portal.menu_gallery')}</li>
<li>{t('portal.menu_calendar')}</li>
<li>{t('portal.menu_rules')}</li>
</ul>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span>
<strong>{loadingStats ? '—' : stats.threads}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_users')}</span>
<strong>{loadingStats ? '—' : stats.users}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_posts')}</span>
<strong>{loadingStats ? '—' : stats.posts}</strong>
</div>
</div>
</aside>
<main className="bb-portal-column bb-portal-column--center">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
{!loadingThreads && recentThreads.length === 0 && (
<p className="bb-muted">{t('portal.empty_posts')}</p>
)}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
<div
className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
style={{ marginLeft: depth * 16 }}
>
<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'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
{node.name}
</Link>
<div className="bb-muted">{node.description || ''}</div>
</div>
</div>
</div>
{recentThreads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={resolveForumName(thread)}
forumId={resolveForumId(thread)}
/>
))}
</div>
)}
</div>
</main>
<aside className="bb-portal-column bb-portal-column--right">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
<div className="bb-portal-user-card">
<Link to="/ucp" className="bb-portal-user-avatar">
{profile?.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
{node.children?.length > 0 && (
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
)}
</Link>
<div className="bb-portal-user-name">
{profile?.id ? (
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
{profile?.name || email || 'User'}
</Link>
) : (
profile?.name || email || 'User'
)}
</div>
<div className="bb-portal-user-role">{roleLabel}</div>
</div>
<ul className="bb-portal-list">
<li>{t('portal.user_new_posts')}</li>
<li>{t('portal.user_unread')}</li>
<li>{t('portal.user_control_panel')}</li>
<li>{t('portal.user_logout')}</li>
</ul>
</div>
<div className="bb-portal-card bb-portal-card--ad">
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
<div className="bb-portal-ad-box">example.com</div>
</div>
</aside>
</div>
{error && <p className="text-danger mt-3">{error}</p>}
</Container>
)
))
return (
<Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.menu')}</div>
<ul className="bb-portal-list">
<li>{t('portal.menu_news')}</li>
<li>{t('portal.menu_gallery')}</li>
<li>{t('portal.menu_calendar')}</li>
<li>{t('portal.menu_rules')}</li>
</ul>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span>
<strong>{loadingStats ? '—' : stats.threads}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_users')}</span>
<strong>{loadingStats ? '—' : stats.users}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_posts')}</span>
<strong>{loadingStats ? '—' : stats.posts}</strong>
</div>
</div>
</aside>
<main className="bb-portal-column bb-portal-column--center">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
{!loadingThreads && recentThreads.length === 0 && (
<p className="bb-muted">{t('portal.empty_posts')}</p>
)}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{recentThreads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={resolveForumName(thread)}
forumId={resolveForumId(thread)}
/>
))}
</div>
)}
</div>
</main>
<aside className="bb-portal-column bb-portal-column--right">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
<div className="bb-portal-user-card">
<Link to="/ucp" className="bb-portal-user-avatar">
{profile?.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</Link>
<div className="bb-portal-user-name">
{profile?.id ? (
<Link
to={`/profile/${profile.id}`}
className="bb-portal-user-name-link"
style={
profile?.rank?.color || profile?.group_color
? { '--bb-user-link-color': profile.rank?.color || profile.group_color }
: undefined
}
>
{profile?.name || email || 'User'}
</Link>
) : (
profile?.name || email || 'User'
)}
</div>
<div className="bb-portal-user-role">{roleLabel}</div>
</div>
<ul className="bb-portal-list">
<li>{t('portal.user_new_posts')}</li>
<li>{t('portal.user_unread')}</li>
<li>{t('portal.user_control_panel')}</li>
<li>{t('portal.user_logout')}</li>
</ul>
</div>
<div className="bb-portal-card bb-portal-card--ad">
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
<div className="bb-portal-ad-box">example.com</div>
</div>
</aside>
</div>
{error && <p className="text-danger mt-3">{error}</p>}
</Container>
)
}

View File

@@ -5,61 +5,61 @@ import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { t } = useTranslation()
const { login } = useAuth()
const navigate = useNavigate()
const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { t } = useTranslation()
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
setLoading(true)
try {
await login(loginValue, password)
navigate('/')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
setLoading(true)
try {
await login(loginValue, password)
navigate('/')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
}
return (
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
<Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control
type="text"
value={loginValue}
onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')}
required
/>
</Form.Group>
<Form.Group className="mb-4">
<Form.Label>{t('form.password')}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</Form.Group>
<Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.signing_in') : t('form.sign_in')}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
)
return (
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
<Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control
type="text"
value={loginValue}
onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')}
required
/>
</Form.Group>
<Form.Group className="mb-4">
<Form.Label>{t('form.password')}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</Form.Group>
<Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.signing_in') : t('form.sign_in')}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
)
}

View File

@@ -2,64 +2,177 @@ import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { getUserProfile } from '../api/client'
import { Link } from 'react-router-dom'
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } from '../api/client'
export default function Profile() {
const { id } = useParams()
const { t } = useTranslation()
const [profile, setProfile] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const { id } = useParams()
const { t } = useTranslation()
const [profile, setProfile] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [thanksGiven, setThanksGiven] = useState([])
const [thanksReceived, setThanksReceived] = useState([])
const [loadingThanks, setLoadingThanks] = useState(true)
useEffect(() => {
let active = true
setLoading(true)
setError('')
useEffect(() => {
let active = true
setLoading(true)
setError('')
getUserProfile(id)
.then((data) => {
if (!active) return
setProfile(data)
})
.catch((err) => {
if (!active) return
setError(err.message)
})
.finally(() => {
if (active) setLoading(false)
})
Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
.then(([profileData, givenData, receivedData]) => {
if (!active) return
setProfile(profileData)
setThanksGiven(Array.isArray(givenData) ? givenData : [])
setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
})
.catch((err) => {
if (!active) return
setError(err.message)
setThanksGiven([])
setThanksReceived([])
})
.finally(() => {
if (!active) return
setLoading(false)
setLoadingThanks(false)
})
return () => {
active = false
return () => {
active = false
}
}, [id])
const formatDateTime = (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())
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
}
}, [id])
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{profile && (
<div className="bb-profile">
<div className="bb-profile-avatar">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{profile && (
<div className="bb-profile">
<div className="bb-profile-avatar">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-profile-meta">
<div className="bb-profile-name">{profile.name}</div>
{profile.created_at && (
<div className="bb-muted">
{t('profile.registered')} {profile.created_at.slice(0, 10)}
</div>
)}
</div>
</div>
)}
{profile && (
<div className="bb-profile-thanks mt-4">
<div className="bb-profile-section">
<div className="bb-portal-card-title">{t('profile.thanks_given')}</div>
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
{!loadingThanks && thanksGiven.length === 0 && (
<p className="bb-muted">{t('profile.thanks_empty')}</p>
)}
{!loadingThanks && thanksGiven.length > 0 && (
<ul className="bb-profile-thanks-list">
{thanksGiven.map((item) => (
<li key={item.id} className="bb-profile-thanks-item">
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
{item.thread_title || t('thread.label')}
</Link>
{item.post_author_id ? (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_for')}{' '}
<Link
to={`/profile/${item.post_author_id}`}
style={
item.post_author_rank_color || item.post_author_group_color
? {
'--bb-user-link-color':
item.post_author_rank_color || item.post_author_group_color,
}
: undefined
}
>
{item.post_author_name || t('thread.anonymous')}
</Link>
</span>
) : (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_for')} {item.post_author_name || t('thread.anonymous')}
</span>
)}
<span className="bb-profile-thanks-date">
{formatDateTime(item.thanked_at)}
</span>
</li>
))}
</ul>
)}
</div>
<div className="bb-profile-section">
<div className="bb-portal-card-title">{t('profile.thanks_received')}</div>
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
{!loadingThanks && thanksReceived.length === 0 && (
<p className="bb-muted">{t('profile.thanks_empty')}</p>
)}
{!loadingThanks && thanksReceived.length > 0 && (
<ul className="bb-profile-thanks-list">
{thanksReceived.map((item) => (
<li key={item.id} className="bb-profile-thanks-item">
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
{item.thread_title || t('thread.label')}
</Link>
{item.thanker_id ? (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_by')}{' '}
<Link
to={`/profile/${item.thanker_id}`}
style={
item.thanker_rank_color || item.thanker_group_color
? {
'--bb-user-link-color':
item.thanker_rank_color || item.thanker_group_color,
}
: undefined
}
>
{item.thanker_name || t('thread.anonymous')}
</Link>
</span>
) : (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_by')} {item.thanker_name || t('thread.anonymous')}
</span>
)}
<span className="bb-profile-thanks-date">
{formatDateTime(item.thanked_at)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
<div className="bb-profile-meta">
<div className="bb-profile-name">{profile.name}</div>
{profile.created_at && (
<div className="bb-muted">
{t('profile.registered')} {profile.created_at.slice(0, 10)}
</div>
)}
</div>
</div>
)}
</div>
</Container>
)
</Container>
)
}

View File

@@ -5,76 +5,76 @@ import { registerUser } from '../api/client'
import { useTranslation } from 'react-i18next'
export default function Register() {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [plainPassword, setPlainPassword] = useState('')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [loading, setLoading] = useState(false)
const { t } = useTranslation()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [plainPassword, setPlainPassword] = useState('')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [loading, setLoading] = useState(false)
const { t } = useTranslation()
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
setNotice('')
setLoading(true)
try {
await registerUser({ email, username, plainPassword })
setNotice(t('auth.verify_notice'))
setEmail('')
setUsername('')
setPlainPassword('')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
setNotice('')
setLoading(true)
try {
await registerUser({ email, username, plainPassword })
setNotice(t('auth.verify_notice'))
setEmail('')
setUsername('')
setPlainPassword('')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
}
return (
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
<Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
{error && <p className="text-danger">{error}</p>}
{notice && <p className="text-success">{notice}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.username')}</Form.Label>
<Form.Control
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-4">
<Form.Label>{t('form.password')}</Form.Label>
<Form.Control
type="password"
value={plainPassword}
onChange={(event) => setPlainPassword(event.target.value)}
minLength={8}
required
/>
</Form.Group>
<Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.registering') : t('form.create_account')}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
)
return (
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
<Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
{error && <p className="text-danger">{error}</p>}
{notice && <p className="text-success">{notice}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.username')}</Form.Label>
<Form.Control
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-4">
<Form.Label>{t('form.password')}</Form.Label>
<Form.Control
type="password"
value={plainPassword}
onChange={(event) => setPlainPassword(event.target.value)}
minLength={8}
required
/>
</Form.Group>
<Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.registering') : t('form.create_account')}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
)
}

View File

@@ -1,278 +1,305 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Container, Form } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { createPost, getThread, listPostsByThread } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function ThreadView() {
const { id } = useParams()
const { token } = useAuth()
const [thread, setThread] = useState(null)
const [posts, setPosts] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const { t } = useTranslation()
const replyRef = useRef(null)
const { id } = useParams()
const { token, userId } = useAuth()
const [thread, setThread] = useState(null)
const [posts, setPosts] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const { t } = useTranslation()
const replyRef = useRef(null)
useEffect(() => {
setLoading(true)
Promise.all([getThread(id), listPostsByThread(id)])
.then(([threadData, postData]) => {
setThread(threadData)
setPosts(postData)
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
setLoading(true)
Promise.all([getThread(id), listPostsByThread(id)])
.then(([threadData, postData]) => {
setThread(threadData)
setPosts(postData)
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
if (!thread && posts.length === 0) return
const hash = window.location.hash
if (!hash) return
const targetId = hash.replace('#', '')
if (!targetId) return
const target = document.getElementById(targetId)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
useEffect(() => {
if (!thread && posts.length === 0) return
const hash = window.location.hash
if (!hash) return
const targetId = hash.replace('#', '')
if (!targetId) return
const target = document.getElementById(targetId)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [thread, posts])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createPost({ body, threadId: id })
setBody('')
const updated = await listPostsByThread(id)
setPosts(updated)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
}, [thread, posts])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createPost({ body, threadId: id })
setBody('')
const updated = await listPostsByThread(id)
setPosts(updated)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
// 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 = {
id: `thread-${thread.id}`,
body: thread.body,
created_at: thread.created_at,
user_id: thread.user_id,
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_location: thread.user_location,
user_thanks_given_count: thread.user_thanks_given_count,
user_thanks_received_count: thread.user_thanks_received_count,
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]
}, [posts, thread])
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 = {
id: `thread-${thread.id}`,
body: thread.body,
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_location: thread.user_location,
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,
const handleJumpToReply = () => {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return [rootPost, ...posts]
}, [posts, thread])
const handleJumpToReply = () => {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const totalPosts = allPosts.length
const totalPosts = allPosts.length
return (
<Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{thread && (
<div className="bb-thread">
<div className="bb-thread-titlebar">
<h1 className="bb-thread-title">{thread.title}</h1>
<div className="bb-thread-meta">
<span>{t('thread.by')}</span>
<span className="bb-thread-author">
{thread.user_name || t('thread.anonymous')}
</span>
{thread.created_at && (
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
)}
</div>
</div>
<div className="bb-thread-toolbar">
<div className="bb-thread-actions">
<Button className="bb-accent-button" onClick={handleJumpToReply}>
<i className="bi bi-reply-fill" aria-hidden="true" />
<span>{t('form.post_reply')}</span>
</Button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
<i className="bi bi-wrench" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
<i className="bi bi-gear" aria-hidden="true" />
</button>
</div>
<div className="bb-thread-meta-right">
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
<span></span>
<span>Page 1 of 1</span>
</div>
</div>
<div className="bb-posts">
{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} id={`post-${post.id}`}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
return (
<Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{thread && (
<div className="bb-thread">
<div className="bb-thread-titlebar">
<h1 className="bb-thread-title">{thread.title}</h1>
<div className="bb-thread-meta">
<span>{t('thread.by')}</span>
<span className="bb-thread-author">
{thread.user_name || t('thread.anonymous')}
</span>
{thread.created_at && (
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
)}
</div>
</div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">
{post.user_rank_name || ''}
<div className="bb-thread-toolbar">
<div className="bb-thread-actions">
<Button className="bb-accent-button" onClick={handleJumpToReply}>
<i className="bi bi-reply-fill" aria-hidden="true" />
<span>{t('form.post_reply')}</span>
</Button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
<i className="bi bi-wrench" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
<i className="bi bi-gear" aria-hidden="true" />
</button>
</div>
<div className="bb-thread-meta-right">
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
<span></span>
<span>Page 1 of 1</span>
</div>
</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 className="bb-posts">
{allPosts.map((post, index) => {
const authorName = post.author?.username
|| post.user_name
|| post.author_name
|| t('thread.anonymous')
const currentUserId = Number(userId)
const postUserId = Number(post.user_id)
const canThank = Number.isFinite(currentUserId)
&& Number.isFinite(postUserId)
&& currentUserId !== postUserId
console.log('canThank check', {
postId: post.id,
postUserId,
currentUserId,
canThank,
})
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} id={`post-${post.id}`}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-post-author-name">{authorName}</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">{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">{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">{t('thread.location')}:</span>
<span className="bb-post-author-value">
{post.user_location || '-'}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_given_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_received_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
</div>
</aside>
<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>
)}
</div>
<div className="bb-post-actions">
<button type="button" className="bb-post-action" aria-label="Edit post">
<i className="bi bi-pencil" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Delete post">
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Report post">
<i className="bi bi-exclamation-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Post info">
<i className="bi bi-info-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Quote post">
<i className="bi bi-quote" aria-hidden="true" />
</button>
{canThank && (
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="bb-post-body">{post.body}</div>
<div className="bb-post-footer">
<div className="bb-post-actions">
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
<i className="bi bi-chevron-up" aria-hidden="true" />
</a>
</div>
</div>
</div>
</article>
)
})}
</div>
<div className="bb-thread-reply" ref={replyRef}>
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
{!token && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
)}
</div>
)}
<div className="bb-post-author-meta">
<div className="bb-post-author-stat">
<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">{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">{t('thread.location')}:</span>
<span className="bb-post-author-value">
{post.user_location || '-'}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks given:</span>
<span className="bb-post-author-value">7</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks received:</span>
<span className="bb-post-author-value">5</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.message')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="bb-thread-reply-actions">
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')}
</Button>
</div>
</Form>
</div>
</aside>
<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>
)}
</div>
<div className="bb-post-actions">
<button type="button" className="bb-post-action" aria-label="Edit post">
<i className="bi bi-pencil" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Delete post">
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Report post">
<i className="bi bi-exclamation-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Post info">
<i className="bi bi-info-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Quote post">
<i className="bi bi-quote" aria-hidden="true" />
</button>
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
<i className="bi bi-house-door" aria-hidden="true" />
</a>
</div>
</div>
<div className="bb-post-body">{post.body}</div>
</div>
</article>
)
})}
</div>
<div className="bb-thread-reply" ref={replyRef}>
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
{!token && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
</div>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.message')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="bb-thread-reply-actions">
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')}
</Button>
</div>
</Form>
</div>
</div>
)}
</Container>
)
</Container>
)
}

View File

@@ -5,175 +5,175 @@ import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
const { t, i18n } = useTranslation()
const { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('')
const [location, setLocation] = useState('')
const [profileError, setProfileError] = useState('')
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
const { t, i18n } = useTranslation()
const { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('')
const [location, setLocation] = useState('')
const [profileError, setProfileError] = useState('')
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
useEffect(() => {
if (!token) return
let active = true
useEffect(() => {
if (!token) return
let active = true
getCurrentUser()
.then((data) => {
if (!active) return
setAvatarPreview(data?.avatar_url || '')
setLocation(data?.location || '')
})
.catch(() => {
if (active) setAvatarPreview('')
})
getCurrentUser()
.then((data) => {
if (!active) return
setAvatarPreview(data?.avatar_url || '')
setLocation(data?.location || '')
})
.catch(() => {
if (active) setAvatarPreview('')
})
return () => {
active = false
return () => {
active = false
}
}, [token])
const handleLanguageChange = (event) => {
const locale = event.target.value
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
}
}, [token])
const handleLanguageChange = (event) => {
const locale = event.target.value
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
}
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card mb-4">
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
<Row className="g-3 align-items-center">
<Col md="auto">
<div className="bb-avatar-preview">
{avatarPreview ? (
<img src={avatarPreview} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card mb-4">
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
<Row className="g-3 align-items-center">
<Col md="auto">
<div className="bb-avatar-preview">
{avatarPreview ? (
<img src={avatarPreview} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
</Col>
<Col>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<Form.Group>
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
disabled={!token || avatarUploading}
onChange={async (event) => {
const file = event.target.files?.[0]
if (!file) return
setAvatarError('')
setAvatarUploading(true)
try {
const response = await uploadAvatar(file)
setAvatarPreview(response.url)
} catch (err) {
setAvatarError(err.message)
} finally {
setAvatarUploading(false)
}
}}
/>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('ucp.location_label')}</Form.Label>
<Form.Control
type="text"
value={location}
disabled={!token || profileSaving}
onChange={(event) => {
setLocation(event.target.value)
setProfileSaved(false)
}}
/>
<Form.Text className="bb-muted">{t('ucp.location_hint')}</Form.Text>
</Form.Group>
{profileError && <p className="text-danger mt-2 mb-0">{profileError}</p>}
{profileSaved && <p className="text-success mt-2 mb-0">{t('ucp.profile_saved')}</p>}
<Button
type="button"
variant="outline-light"
className="mt-3"
disabled={!token || profileSaving}
onClick={async () => {
setProfileError('')
setProfileSaved(false)
setProfileSaving(true)
try {
const response = await updateCurrentUser({ location })
setLocation(response?.location || '')
setProfileSaved(true)
} catch (err) {
setProfileError(err.message)
} finally {
setProfileSaving(false)
}
}}
>
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
</Button>
</Col>
</Row>
</div>
</Col>
<Col>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<Form.Group>
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
disabled={!token || avatarUploading}
onChange={async (event) => {
const file = event.target.files?.[0]
if (!file) return
setAvatarError('')
setAvatarUploading(true)
try {
const response = await uploadAvatar(file)
setAvatarPreview(response.url)
} catch (err) {
setAvatarError(err.message)
} finally {
setAvatarUploading(false)
}
}}
/>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('ucp.location_label')}</Form.Label>
<Form.Control
type="text"
value={location}
disabled={!token || profileSaving}
onChange={(event) => {
setLocation(event.target.value)
setProfileSaved(false)
}}
/>
<Form.Text className="bb-muted">{t('ucp.location_hint')}</Form.Text>
</Form.Group>
{profileError && <p className="text-danger mt-2 mb-0">{profileError}</p>}
{profileSaved && <p className="text-success mt-2 mb-0">{t('ucp.profile_saved')}</p>}
<Button
type="button"
variant="outline-light"
className="mt-3"
disabled={!token || profileSaving}
onClick={async () => {
setProfileError('')
setProfileSaved(false)
setProfileSaving(true)
try {
const response = await updateCurrentUser({ location })
setLocation(response?.location || '')
setProfileSaved(true)
} catch (err) {
setProfileError(err.message)
} finally {
setProfileSaving(false)
}
}}
>
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
</Button>
</Col>
</Row>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
<Row className="g-3">
<Col xs={12}>
<Form.Group>
<Form.Label>{t('nav.language')}</Form.Label>
<Form.Select value={i18n.language} onChange={handleLanguageChange}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('nav.theme')}</Form.Label>
<Form.Select value={theme} onChange={(event) => setTheme(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 md={6}>
<Form.Group>
<Form.Label>{t('ucp.accent_override')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Select
value={accentMode}
onChange={(event) => {
const mode = event.target.value
if (mode === 'system') {
setAccentOverride('')
} else if (!accentOverride) {
setAccentOverride('#f29b3f')
}
}}
>
<option value="system">{t('ucp.system_default')}</option>
<option value="custom">{t('ucp.custom_color')}</option>
</Form.Select>
<Form.Control
type="color"
value={accentOverride || '#f29b3f'}
onChange={(event) => setAccentOverride(event.target.value)}
disabled={accentMode !== 'custom'}
/>
</div>
<Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
</Container>
)
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
<Row className="g-3">
<Col xs={12}>
<Form.Group>
<Form.Label>{t('nav.language')}</Form.Label>
<Form.Select value={i18n.language} onChange={handleLanguageChange}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('nav.theme')}</Form.Label>
<Form.Select value={theme} onChange={(event) => setTheme(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 md={6}>
<Form.Group>
<Form.Label>{t('ucp.accent_override')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Select
value={accentMode}
onChange={(event) => {
const mode = event.target.value
if (mode === 'system') {
setAccentOverride('')
} else if (!accentOverride) {
setAccentOverride('#f29b3f')
}
}}
>
<option value="system">{t('ucp.system_default')}</option>
<option value="custom">{t('ucp.custom_color')}</option>
</Form.Select>
<Form.Control
type="color"
value={accentOverride || '#f29b3f'}
onChange={(event) => setAccentOverride(event.target.value)}
disabled={accentMode !== 'custom'}
/>
</div>
<Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
</Container>
)
}