Forum tree model, i18n, and frontend updates
This commit is contained in:
137
frontend/package-lock.json
generated
137
frontend/package-lock.json
generated
@@ -9,9 +9,12 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1659,6 +1662,14 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2113,6 +2124,52 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.7.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
|
||||
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2346,6 +2403,25 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -2572,6 +2648,32 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz",
|
||||
"integrity": "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.6.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -2781,6 +2883,11 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2851,6 +2958,14 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
@@ -2925,6 +3040,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
@@ -2933,6 +3056,20 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,43 +1,58 @@
|
||||
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
|
||||
import { Container, Nav, Navbar } from 'react-bootstrap'
|
||||
import { Container, Nav, Navbar, NavDropdown } from 'react-bootstrap'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Home from './pages/Home'
|
||||
import CategoryView from './pages/CategoryView'
|
||||
import ForumView from './pages/ForumView'
|
||||
import ThreadView from './pages/ThreadView'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function Navigation() {
|
||||
const { token, email, logout } = useAuth()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const handleLanguageChange = (locale) => {
|
||||
i18n.changeLanguage(locale)
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<Navbar expand="lg" className="bb-nav">
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/" className="fw-semibold">
|
||||
speedBB
|
||||
{t('app.brand')}
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="bb-nav" />
|
||||
<Navbar.Collapse id="bb-nav">
|
||||
<Nav className="ms-auto align-items-lg-center gap-2">
|
||||
<Nav.Link as={Link} to="/">
|
||||
Categories
|
||||
{t('nav.forums')}
|
||||
</Nav.Link>
|
||||
{!token && (
|
||||
<>
|
||||
<Nav.Link as={Link} to="/login">
|
||||
Login
|
||||
{t('nav.login')}
|
||||
</Nav.Link>
|
||||
<Nav.Link as={Link} to="/register">
|
||||
Register
|
||||
{t('nav.register')}
|
||||
</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
{token && (
|
||||
<>
|
||||
<span className="bb-chip">{email}</span>
|
||||
<Nav.Link onClick={logout}>Logout</Nav.Link>
|
||||
<Nav.Link onClick={logout}>{t('nav.logout')}</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
<NavDropdown title={t('nav.language')} align="end">
|
||||
<NavDropdown.Item onClick={() => handleLanguageChange('en')}>
|
||||
English
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleLanguageChange('de')}>
|
||||
Deutsch
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
@@ -46,19 +61,21 @@ function Navigation() {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="bb-shell">
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/category/:id" element={<CategoryView />} />
|
||||
<Route path="/forum/:id" element={<ForumView />} />
|
||||
<Route path="/thread/:id" element={<ThreadView />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Routes>
|
||||
<footer className="bb-footer">
|
||||
<Container>
|
||||
speedBB forum. Powered by API Platform and React-Bootstrap.
|
||||
{t('footer.copy')}
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -50,16 +50,20 @@ export async function registerUser({ email, username, plainPassword }) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function listCategories() {
|
||||
return getCollection('/categories')
|
||||
export async function listRootForums() {
|
||||
return getCollection('/forums?parent[exists]=false')
|
||||
}
|
||||
|
||||
export async function getCategory(id) {
|
||||
return apiFetch(`/categories/${id}`)
|
||||
export async function listForumsByParent(parentId) {
|
||||
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
||||
}
|
||||
|
||||
export async function listThreadsByCategory(categoryId) {
|
||||
return getCollection(`/threads?category=/api/categories/${categoryId}`)
|
||||
export async function getForum(id) {
|
||||
return apiFetch(`/forums/${id}`)
|
||||
}
|
||||
|
||||
export async function listThreadsByForum(forumId) {
|
||||
return getCollection(`/threads?forum=/api/forums/${forumId}`)
|
||||
}
|
||||
|
||||
export async function getThread(id) {
|
||||
@@ -70,13 +74,13 @@ export async function listPostsByThread(threadId) {
|
||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||
}
|
||||
|
||||
export async function createThread({ title, body, categoryId }) {
|
||||
export async function createThread({ title, body, forumId }) {
|
||||
return apiFetch('/threads', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
category: `/api/categories/${categoryId}`,
|
||||
forum: `/api/forums/${forumId}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
25
frontend/src/i18n.js
Normal file
25
frontend/src/i18n.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import i18n from 'i18next'
|
||||
import HttpBackend from 'i18next-http-backend'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
const storedLanguage = localStorage.getItem('speedbb_lang') || 'en'
|
||||
|
||||
i18n
|
||||
.use(HttpBackend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: storedLanguage,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de'],
|
||||
backend: {
|
||||
loadPath: '/api/i18n/{{lng}}',
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getCategory, listThreadsByCategory } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function CategoryView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const [category, setCategory] = useState(null)
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([getCategory(id), listThreadsByCategory(id)])
|
||||
.then(([categoryData, threadData]) => {
|
||||
setCategory(categoryData)
|
||||
setThreads(threadData)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await createThread({ title, body, categoryId: id })
|
||||
setTitle('')
|
||||
setBody('')
|
||||
const updated = await listThreadsByCategory(id)
|
||||
setThreads(updated)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">Loading category...</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{category && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">Category</p>
|
||||
<h2 className="mt-3">{category.name}</h2>
|
||||
<p className="bb-muted mb-0">
|
||||
{category.description || 'No description added yet.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">Threads</h4>
|
||||
{threads.length === 0 && (
|
||||
<p className="bb-muted">No threads here yet. Start one below.</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">
|
||||
View thread
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">Start a thread</h4>
|
||||
<div className="bb-form">
|
||||
{!token && (
|
||||
<p className="bb-muted mb-3">Log in to create a new thread.</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Topic headline"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Body</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Share the context and your question."
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
||||
{saving ? 'Posting...' : 'Create thread'}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
180
frontend/src/pages/ForumView.jsx
Normal file
180
frontend/src/pages/ForumView.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||
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 [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
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)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Col, Container, Row } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listCategories } from '../api/client'
|
||||
import { listRootForums } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Home() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [forums, setForums] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listCategories()
|
||||
.then(setCategories)
|
||||
listRootForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
@@ -18,31 +20,30 @@ export default function Home() {
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">speedBB</p>
|
||||
<h1 className="mt-3">Forum categories</h1>
|
||||
<p className="bb-chip">{t('app.brand')}</p>
|
||||
<h1 className="mt-3">{t('home.hero_title')}</h1>
|
||||
<p className="bb-muted mb-0">
|
||||
Explore conversations, ask questions, and share ideas. Start in a category
|
||||
that matches your topic.
|
||||
{t('home.hero_body')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="bb-section-title mb-3">Browse categories</h3>
|
||||
{loading && <p className="bb-muted">Loading categories...</p>}
|
||||
<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 && categories.length === 0 && (
|
||||
<p className="bb-muted">No categories yet. Create the first one in the API.</p>
|
||||
{!loading && forums.length === 0 && (
|
||||
<p className="bb-muted">{t('home.empty')}</p>
|
||||
)}
|
||||
<Row xs={1} md={2} lg={3} className="g-4">
|
||||
{categories.map((category) => (
|
||||
<Col key={category.id}>
|
||||
{forums.map((forum) => (
|
||||
<Col key={forum.id}>
|
||||
<Card className="bb-card h-100">
|
||||
<Card.Body>
|
||||
<Card.Title>{category.name}</Card.Title>
|
||||
<Card.Title>{forum.name}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{category.description || 'No description yet.'}
|
||||
{forum.description || t('forum.no_description')}
|
||||
</Card.Text>
|
||||
<Link to={`/category/${category.id}`} className="stretched-link">
|
||||
Open category
|
||||
<Link to={`/forum/${forum.id}`} className="stretched-link">
|
||||
{t('forum.open')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
@@ -10,6 +11,7 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -29,14 +31,12 @@ export default function Login() {
|
||||
<Container className="py-5">
|
||||
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
||||
<Card.Body>
|
||||
<Card.Title className="mb-3">Log in</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
Access your account to start new threads and reply.
|
||||
</Card.Text>
|
||||
<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>Email</Form.Label>
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -45,7 +45,7 @@ export default function Login() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -54,7 +54,7 @@ export default function Login() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { registerUser } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate()
|
||||
@@ -10,6 +11,7 @@ export default function Register() {
|
||||
const [plainPassword, setPlainPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -29,14 +31,12 @@ export default function Register() {
|
||||
<Container className="py-5">
|
||||
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
||||
<Card.Body>
|
||||
<Card.Title className="mb-3">Create account</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
Register with an email and a unique username.
|
||||
</Card.Text>
|
||||
<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>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -45,7 +45,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={username}
|
||||
@@ -54,7 +54,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={plainPassword}
|
||||
@@ -64,7 +64,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? 'Registering...' : 'Create account'}
|
||||
{loading ? t('form.registering') : t('form.create_account')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, 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()
|
||||
@@ -13,6 +14,7 @@ export default function ThreadView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -43,19 +45,19 @@ export default function ThreadView() {
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">Loading thread...</p>}
|
||||
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{thread && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">Thread</p>
|
||||
<p className="bb-chip">{t('thread.label')}</p>
|
||||
<h2 className="mt-3">{thread.title}</h2>
|
||||
<p className="bb-muted mb-2">{thread.body}</p>
|
||||
{thread.category && (
|
||||
{thread.forum && (
|
||||
<p className="bb-muted mb-0">
|
||||
Category:{' '}
|
||||
<Link to={`/category/${thread.category.id || thread.category.split('/').pop()}`}>
|
||||
{thread.category.name || 'Back to category'}
|
||||
{t('thread.category')}{' '}
|
||||
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
|
||||
{thread.forum.name || t('thread.back_to_category')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
@@ -63,32 +65,34 @@ export default function ThreadView() {
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">Replies</h4>
|
||||
{posts.length === 0 && <p className="bb-muted">Be the first to reply.</p>}
|
||||
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
|
||||
{posts.length === 0 && (
|
||||
<p className="bb-muted">{t('thread.empty')}</p>
|
||||
)}
|
||||
{posts.map((post) => (
|
||||
<Card className="bb-card mb-3" key={post.id}>
|
||||
<Card.Body>
|
||||
<Card.Text>{post.body}</Card.Text>
|
||||
<small className="bb-muted">
|
||||
{post.author?.username || 'Anonymous'}
|
||||
{post.author?.username || t('thread.anonymous')}
|
||||
</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">Reply</h4>
|
||||
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4>
|
||||
<div className="bb-form">
|
||||
{!token && (
|
||||
<p className="bb-muted mb-3">Log in to reply to this thread.</p>
|
||||
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Message</Form.Label>
|
||||
<Form.Label>{t('form.message')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Share your reply."
|
||||
placeholder={t('form.reply_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
@@ -96,7 +100,7 @@ export default function ThreadView() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
||||
{saving ? 'Posting...' : 'Post reply'}
|
||||
{saving ? t('form.posting') : t('form.post_reply')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user