feat: add installer, ranks/groups enhancements, and founder protections
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user