Forum tree model, i18n, and frontend updates
This commit is contained in:
@@ -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