170 lines
6.1 KiB
JavaScript
170 lines
6.1 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Button, Container, Form } 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()
|
|
const { token } = useAuth()
|
|
const [thread, setThread] = useState(null)
|
|
const [posts, setPosts] = useState([])
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [body, setBody] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const { t } = useTranslation()
|
|
const replyRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
Promise.all([getThread(id), listPostsByThread(id)])
|
|
.then(([threadData, postData]) => {
|
|
setThread(threadData)
|
|
setPosts(postData)
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false))
|
|
}, [id])
|
|
|
|
const handleSubmit = async (event) => {
|
|
event.preventDefault()
|
|
setSaving(true)
|
|
setError('')
|
|
try {
|
|
await createPost({ body, threadId: id })
|
|
setBody('')
|
|
const updated = await listPostsByThread(id)
|
|
setPosts(updated)
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const replyCount = posts.length
|
|
const allPosts = useMemo(() => {
|
|
if (!thread) return posts
|
|
const rootPost = {
|
|
id: `thread-${thread.id}`,
|
|
body: thread.body,
|
|
created_at: thread.created_at,
|
|
user_name: thread.user_name,
|
|
isRoot: true,
|
|
}
|
|
return [rootPost, ...posts]
|
|
}, [posts, thread])
|
|
|
|
const handleJumpToReply = () => {
|
|
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}
|
|
|
|
const totalPosts = allPosts.length
|
|
|
|
return (
|
|
<Container className="py-4">
|
|
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
|
{error && <p className="text-danger">{error}</p>}
|
|
{thread && (
|
|
<div className="bb-thread">
|
|
<div className="bb-thread-titlebar">
|
|
<h1 className="bb-thread-title">{thread.title}</h1>
|
|
<div className="bb-thread-meta">
|
|
<span>{t('thread.by')}</span>
|
|
<span className="bb-thread-author">
|
|
{thread.user_name || t('thread.anonymous')}
|
|
</span>
|
|
{thread.created_at && (
|
|
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bb-thread-toolbar">
|
|
<div className="bb-thread-actions">
|
|
<Button className="bb-accent-button" onClick={handleJumpToReply}>
|
|
<i className="bi bi-reply-fill" aria-hidden="true" />
|
|
<span>{t('form.post_reply')}</span>
|
|
</Button>
|
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
|
|
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
|
|
</button>
|
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
|
|
<i className="bi bi-wrench" aria-hidden="true" />
|
|
</button>
|
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
|
|
<i className="bi bi-gear" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div className="bb-thread-meta-right">
|
|
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
|
|
<span>•</span>
|
|
<span>Page 1 of 1</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bb-posts">
|
|
{allPosts.map((post) => {
|
|
const authorName = post.author?.username
|
|
|| post.user_name
|
|
|| post.author_name
|
|
|| t('thread.anonymous')
|
|
|
|
return (
|
|
<article className="bb-post-row" key={post.id}>
|
|
<aside className="bb-post-author">
|
|
<div className="bb-post-avatar">
|
|
<i className="bi bi-person" aria-hidden="true" />
|
|
</div>
|
|
<div className="bb-post-author-name">{authorName}</div>
|
|
<div className="bb-post-author-meta">
|
|
{post.isRoot ? t('thread.label') : t('thread.reply')}
|
|
</div>
|
|
</aside>
|
|
<div className="bb-post-content">
|
|
<div className="bb-post-header">
|
|
<span>{t('thread.by')} {authorName}</span>
|
|
{post.created_at && (
|
|
<span>{post.created_at.slice(0, 10)}</span>
|
|
)}
|
|
</div>
|
|
<div className="bb-post-body">{post.body}</div>
|
|
</div>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="bb-thread-reply" ref={replyRef}>
|
|
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
|
|
{!token && (
|
|
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
|
)}
|
|
<Form onSubmit={handleSubmit}>
|
|
<Form.Group className="mb-3">
|
|
<Form.Label>{t('form.message')}</Form.Label>
|
|
<Form.Control
|
|
as="textarea"
|
|
rows={6}
|
|
placeholder={t('form.reply_placeholder')}
|
|
value={body}
|
|
onChange={(event) => setBody(event.target.value)}
|
|
disabled={!token || saving}
|
|
required
|
|
/>
|
|
</Form.Group>
|
|
<div className="bb-thread-reply-actions">
|
|
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
|
{saving ? t('form.posting') : t('form.post_reply')}
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Container>
|
|
)
|
|
}
|