UI: portal/header refinements, board index UX, and user settings
This commit is contained in:
@@ -12,6 +12,7 @@ export default function Acp({ isAdmin }) {
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [overId, setOverId] = useState(null)
|
||||
const pendingOrder = useRef(null)
|
||||
const [createType, setCreateType] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
@@ -232,7 +233,7 @@ export default function Acp({ isAdmin }) {
|
||||
setError('')
|
||||
try {
|
||||
const data = await listAllForums()
|
||||
setForums(data)
|
||||
setForums(data.filter((forum) => !forum.deleted_at))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -394,6 +395,18 @@ export default function Acp({ isAdmin }) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleStartCreateChild = (type, parentId) => {
|
||||
setSelectedId(null)
|
||||
setShowModal(true)
|
||||
setCreateType(type)
|
||||
setForm({
|
||||
name: '',
|
||||
description: '',
|
||||
type,
|
||||
parentId: parentId ? String(parentId) : '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
@@ -402,6 +415,10 @@ export default function Acp({ isAdmin }) {
|
||||
setError(t('acp.forums_name_required'))
|
||||
return
|
||||
}
|
||||
if (form.type === 'forum' && !form.parentId) {
|
||||
setError(t('acp.forums_parent_required'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (selectedId) {
|
||||
await updateForum(selectedId, {
|
||||
@@ -448,6 +465,11 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (pendingOrder.current) {
|
||||
const { parentId, ordered } = pendingOrder.current
|
||||
pendingOrder.current = null
|
||||
reorderForums(parentId, ordered).catch((err) => setError(err.message))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setOverId(null)
|
||||
}
|
||||
@@ -504,6 +526,7 @@ export default function Acp({ isAdmin }) {
|
||||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||||
setOverId(String(targetId))
|
||||
applyLocalOrder(parentId, ordered)
|
||||
pendingOrder.current = { parentId, ordered }
|
||||
}
|
||||
|
||||
const handleDragEnter = (forumId) => {
|
||||
@@ -550,6 +573,7 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
|
||||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||||
pendingOrder.current = null
|
||||
|
||||
try {
|
||||
await reorderForums(parentId, ordered)
|
||||
@@ -622,6 +646,24 @@ export default function Acp({ isAdmin }) {
|
||||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||||
</span>
|
||||
<ButtonGroup size="sm" className="bb-action-group">
|
||||
{node.type === 'category' && (
|
||||
<>
|
||||
<Button
|
||||
variant="dark"
|
||||
onClick={() => handleStartCreateChild('category', node.id)}
|
||||
title={t('acp.add_category')}
|
||||
>
|
||||
<i className="bi bi-folder-plus" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="dark"
|
||||
onClick={() => handleStartCreateChild('forum', node.id)}
|
||||
title={t('acp.add_forum')}
|
||||
>
|
||||
<i className="bi bi-chat-left-text" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -793,7 +835,9 @@ export default function Acp({ isAdmin }) {
|
||||
value={form.parentId}
|
||||
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
|
||||
>
|
||||
<option value="">{t('acp.forums_parent_root')}</option>
|
||||
<option value="" disabled={form.type === 'forum'}>
|
||||
{t('acp.forums_parent_root')}
|
||||
</option>
|
||||
{categoryOptions
|
||||
.filter((option) => String(option.id) !== String(selectedId))
|
||||
.map((option) => (
|
||||
|
||||
212
resources/js/pages/BoardIndex.jsx
Normal file
212
resources/js/pages/BoardIndex.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchUserSetting, listAllForums, saveUserSetting } from '../api/client'
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
} 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
|
||||
})
|
||||
}
|
||||
setCollapsed(next)
|
||||
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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 || t('forum.no_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">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--last">
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
return (
|
||||
<Container 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>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -13,11 +13,36 @@ export default function ForumView() {
|
||||
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 || t('forum.no_description')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-board-cell bb-board-cell--topics">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--last">
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
@@ -62,6 +87,7 @@ export default function ForumView() {
|
||||
setBody('')
|
||||
const updated = await listThreadsByForum(id)
|
||||
setThreads(updated)
|
||||
setShowModal(false)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -75,106 +101,152 @@ export default function ForumView() {
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{forum && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">
|
||||
{forum.type === 'forum' ? t('forum.type_forum') : t('forum.type_category')}
|
||||
</p>
|
||||
<h2 className="mt-3">{forum.name}</h2>
|
||||
<p className="bb-muted mb-0">
|
||||
{forum.description || t('forum.no_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">{t('forum.children')}</h4>
|
||||
{children.length === 0 && (
|
||||
<p className="bb-muted">{t('forum.empty_children')}</p>
|
||||
<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>
|
||||
)}
|
||||
{children.map((child) => (
|
||||
<Card className="bb-card mb-3" key={child.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{child.name}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{child.description || t('forum.no_description')}
|
||||
</Card.Text>
|
||||
<Link to={`/forum/${child.id}`} className="stretched-link">
|
||||
{t('forum.open')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{forum.type === 'forum' && (
|
||||
<>
|
||||
<h4 className="bb-section-title mb-3 mt-4">{t('forum.threads')}</h4>
|
||||
{threads.length === 0 && (
|
||||
<p className="bb-muted">{t('forum.empty_threads')}</p>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<Card className="bb-card mb-3" key={thread.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{thread.title}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{thread.body.length > 160
|
||||
? `${thread.body.slice(0, 160)}...`
|
||||
: thread.body}
|
||||
</Card.Text>
|
||||
<Link to={`/thread/${thread.id}`} className="stretched-link">
|
||||
{t('thread.view')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
<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-topic-table">
|
||||
<div className="bb-topic-header">
|
||||
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
|
||||
</div>
|
||||
{threads.length === 0 && (
|
||||
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<div className="bb-topic-row" key={thread.id}>
|
||||
<div className="bb-topic-cell bb-topic-cell--title">
|
||||
<div className="bb-topic-title">
|
||||
<span className="bb-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left" />
|
||||
</span>
|
||||
<div className="bb-topic-text">
|
||||
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
|
||||
<div className="bb-topic-meta">
|
||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||
<span>{t('thread.by')}</span>
|
||||
<span className="bb-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-topic-date">
|
||||
{thread.created_at.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">{t('forum.start_thread')}</h4>
|
||||
<div className="bb-form">
|
||||
{forum.type !== 'forum' && (
|
||||
<p className="bb-muted mb-3">{t('forum.only_forums')}</p>
|
||||
)}
|
||||
{forum.type === 'forum' && !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 || forum.type !== 'forum'}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.body')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder={t('form.thread_body_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
>
|
||||
{saving ? t('form.posting') : t('form.create_thread')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Badge, Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listAllForums } from '../api/client'
|
||||
import { listAllForums, listThreads } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Home() {
|
||||
const [forums, setForums] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingForums, setLoadingForums] = useState(true)
|
||||
const [loadingThreads, setLoadingThreads] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
.finally(() => setLoadingForums(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingThreads(false))
|
||||
}, [])
|
||||
|
||||
const getParentId = (forum) => {
|
||||
@@ -56,6 +65,33 @@ export default function Home() {
|
||||
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 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}>
|
||||
@@ -82,22 +118,111 @@ export default function Home() {
|
||||
))
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">{t('app.brand')}</p>
|
||||
<h1 className="mt-3">{t('home.hero_title')}</h1>
|
||||
<p className="bb-muted mb-0">
|
||||
{t('home.hero_body')}
|
||||
</p>
|
||||
</div>
|
||||
<Container 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>{threads.length}</strong>
|
||||
</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_forums')}</span>
|
||||
<strong>{forums.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
|
||||
{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="mt-2">{renderTree(forumTree)}</div>}
|
||||
<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) => (
|
||||
<div className="bb-portal-topic-row" key={thread.id}>
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
<Badge bg="secondary">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</Badge>
|
||||
<span className="bb-portal-topic-forum">
|
||||
{t('portal.forum_label')}{' '}
|
||||
{resolveForumId(thread) ? (
|
||||
<Link
|
||||
to={`/forum/${resolveForumId(thread)}`}
|
||||
className="bb-portal-topic-forum-link"
|
||||
>
|
||||
{resolveForumName(thread)}
|
||||
</Link>
|
||||
) : (
|
||||
resolveForumName(thread)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">0</div>
|
||||
<div className="bb-portal-topic-cell">—</div>
|
||||
<div className="bb-portal-topic-cell">
|
||||
{thread.created_at?.slice(0, 10) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<div className="bb-portal-user-avatar" />
|
||||
<div className="bb-portal-user-name">tracer</div>
|
||||
<div className="bb-portal-user-role">Operator</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>
|
||||
)
|
||||
}
|
||||
|
||||
71
resources/js/pages/Ucp.jsx
Normal file
71
resources/js/pages/Ucp.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Container, Form, Row, Col } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const accentMode = accentOverride ? 'custom' : 'system'
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const locale = event.target.value
|
||||
i18n.changeLanguage(locale)
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5 bb-portal-shell">
|
||||
<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