feat: add installer, ranks/groups enhancements, and founder protections
This commit is contained in:
@@ -1,278 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Container, Form } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { 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)
|
||||
const { id } = useParams()
|
||||
const { token, userId } = 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])
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread && posts.length === 0) return
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
const targetId = hash.replace('#', '')
|
||||
if (!targetId) return
|
||||
const target = document.getElementById(targetId)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
useEffect(() => {
|
||||
if (!thread && posts.length === 0) return
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
const targetId = hash.replace('#', '')
|
||||
if (!targetId) return
|
||||
const target = document.getElementById(targetId)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [thread, posts])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}, [thread, posts])
|
||||
|
||||
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 formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
}
|
||||
const allPosts = useMemo(() => {
|
||||
if (!thread) return posts
|
||||
const rootPost = {
|
||||
id: `thread-${thread.id}`,
|
||||
body: thread.body,
|
||||
created_at: thread.created_at,
|
||||
user_id: thread.user_id,
|
||||
user_name: thread.user_name,
|
||||
user_avatar_url: thread.user_avatar_url,
|
||||
user_posts_count: thread.user_posts_count,
|
||||
user_created_at: thread.user_created_at,
|
||||
user_location: thread.user_location,
|
||||
user_thanks_given_count: thread.user_thanks_given_count,
|
||||
user_thanks_received_count: thread.user_thanks_received_count,
|
||||
user_rank_name: thread.user_rank_name,
|
||||
user_rank_badge_type: thread.user_rank_badge_type,
|
||||
user_rank_badge_text: thread.user_rank_badge_text,
|
||||
user_rank_badge_url: thread.user_rank_badge_url,
|
||||
isRoot: true,
|
||||
}
|
||||
return [rootPost, ...posts]
|
||||
}, [posts, thread])
|
||||
|
||||
const replyCount = posts.length
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
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,
|
||||
user_avatar_url: thread.user_avatar_url,
|
||||
user_posts_count: thread.user_posts_count,
|
||||
user_created_at: thread.user_created_at,
|
||||
user_location: thread.user_location,
|
||||
user_rank_name: thread.user_rank_name,
|
||||
user_rank_badge_type: thread.user_rank_badge_type,
|
||||
user_rank_badge_text: thread.user_rank_badge_text,
|
||||
user_rank_badge_url: thread.user_rank_badge_url,
|
||||
isRoot: true,
|
||||
const handleJumpToReply = () => {
|
||||
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
return [rootPost, ...posts]
|
||||
}, [posts, thread])
|
||||
|
||||
const handleJumpToReply = () => {
|
||||
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
const totalPosts = allPosts.length
|
||||
|
||||
const totalPosts = allPosts.length
|
||||
|
||||
return (
|
||||
<Container fluid className="py-4 bb-shell-container">
|
||||
{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, index) => {
|
||||
const authorName = post.author?.username
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
: `${t('thread.reply_prefix')} ${thread.title}`
|
||||
: ''
|
||||
const postNumber = index + 1
|
||||
|
||||
return (
|
||||
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
|
||||
<aside className="bb-post-author">
|
||||
<div className="bb-post-avatar">
|
||||
{post.user_avatar_url ? (
|
||||
<img src={post.user_avatar_url} alt="" />
|
||||
) : (
|
||||
<i className="bi bi-person" aria-hidden="true" />
|
||||
)}
|
||||
return (
|
||||
<Container fluid className="py-4 bb-shell-container">
|
||||
{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-post-author-name">{authorName}</div>
|
||||
<div className="bb-post-author-role">
|
||||
{post.user_rank_name || ''}
|
||||
|
||||
<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>
|
||||
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
|
||||
<div className="bb-post-author-badge">
|
||||
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
|
||||
<img src={post.user_rank_badge_url} alt="" />
|
||||
) : (
|
||||
<span>{post.user_rank_badge_text}</span>
|
||||
|
||||
<div className="bb-posts">
|
||||
{allPosts.map((post, index) => {
|
||||
const authorName = post.author?.username
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const currentUserId = Number(userId)
|
||||
const postUserId = Number(post.user_id)
|
||||
const canThank = Number.isFinite(currentUserId)
|
||||
&& Number.isFinite(postUserId)
|
||||
&& currentUserId !== postUserId
|
||||
console.log('canThank check', {
|
||||
postId: post.id,
|
||||
postUserId,
|
||||
currentUserId,
|
||||
canThank,
|
||||
})
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
: `${t('thread.reply_prefix')} ${thread.title}`
|
||||
: ''
|
||||
const postNumber = index + 1
|
||||
|
||||
return (
|
||||
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
|
||||
<aside className="bb-post-author">
|
||||
<div className="bb-post-avatar">
|
||||
{post.user_avatar_url ? (
|
||||
<img src={post.user_avatar_url} alt="" />
|
||||
) : (
|
||||
<i className="bi bi-person" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-author-name">{authorName}</div>
|
||||
<div className="bb-post-author-role">
|
||||
{post.user_rank_name || ''}
|
||||
</div>
|
||||
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
|
||||
<div className="bb-post-author-badge">
|
||||
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
|
||||
<img src={post.user_rank_badge_url} alt="" />
|
||||
) : (
|
||||
<span>{post.user_rank_badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bb-post-author-meta">
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.posts')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_posts_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.registered')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{formatDate(post.user_created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.location')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_location || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_given_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_received_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat bb-post-author-contact">
|
||||
<span className="bb-post-author-label">Contact:</span>
|
||||
<span className="bb-post-author-value">
|
||||
<i className="bi bi-chat-dots" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="bb-post-content">
|
||||
<div className="bb-post-header">
|
||||
<div className="bb-post-header-meta">
|
||||
{topicLabel && (
|
||||
<span className="bb-post-topic">
|
||||
#{postNumber} {topicLabel}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('thread.by')} {authorName}</span>
|
||||
{post.created_at && (
|
||||
<span>{post.created_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-actions">
|
||||
<button type="button" className="bb-post-action" aria-label="Edit post">
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Delete post">
|
||||
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Report post">
|
||||
<i className="bi bi-exclamation-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Post info">
|
||||
<i className="bi bi-info-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||
<i className="bi bi-quote" aria-hidden="true" />
|
||||
</button>
|
||||
{canThank && (
|
||||
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
|
||||
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-post-body">{post.body}</div>
|
||||
<div className="bb-post-footer">
|
||||
<div className="bb-post-actions">
|
||||
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-chevron-up" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bb-post-author-meta">
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.posts')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_posts_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.registered')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{formatDate(post.user_created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.location')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_location || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Thanks given:</span>
|
||||
<span className="bb-post-author-value">7</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Thanks received:</span>
|
||||
<span className="bb-post-author-value">5</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat bb-post-author-contact">
|
||||
<span className="bb-post-author-label">Contact:</span>
|
||||
<span className="bb-post-author-value">
|
||||
<i className="bi bi-chat-dots" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</aside>
|
||||
<div className="bb-post-content">
|
||||
<div className="bb-post-header">
|
||||
<div className="bb-post-header-meta">
|
||||
{topicLabel && (
|
||||
<span className="bb-post-topic">
|
||||
#{postNumber} {topicLabel}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('thread.by')} {authorName}</span>
|
||||
{post.created_at && (
|
||||
<span>{post.created_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-actions">
|
||||
<button type="button" className="bb-post-action" aria-label="Edit post">
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Delete post">
|
||||
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Report post">
|
||||
<i className="bi bi-exclamation-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Post info">
|
||||
<i className="bi bi-info-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||
<i className="bi bi-quote" aria-hidden="true" />
|
||||
</button>
|
||||
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-house-door" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user