Forum tree model, i18n, and frontend updates

This commit is contained in:
Micha
2025-12-24 13:15:02 +01:00
parent 98a2f1d536
commit 193273c843
29 changed files with 1115 additions and 218 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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
View 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

View File

@@ -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(

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>