Restyle thread view like phpBB
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
- Added admin-only upload endpoints and ACP UI for logos and favicons.
|
- Added admin-only upload endpoints and ACP UI for logos and favicons.
|
||||||
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
|
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
|
||||||
|
|
||||||
|
## 2026-01-11
|
||||||
|
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
||||||
|
|
||||||
## 2025-12-30
|
## 2025-12-30
|
||||||
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
|
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
|
||||||
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
|
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
|
||||||
|
|||||||
@@ -88,6 +88,210 @@ a {
|
|||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-thread {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-titlebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-author {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-date {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--bb-border);
|
||||||
|
background: #141822;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-toolbar .bb-accent-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-icon-button {
|
||||||
|
border: 1px solid #2a2f3a;
|
||||||
|
background: #20252f;
|
||||||
|
color: #d0d6df;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-icon-button:hover {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
border-color: var(--bb-accent, #f29b3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-meta-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-empty {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-posts {
|
||||||
|
border: 1px solid var(--bb-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #171b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
border-top: 1px solid var(--bb-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-row:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author {
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-right: 1px solid var(--bb-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-content {
|
||||||
|
padding: 1rem 1.35rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--bb-ink);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-reply {
|
||||||
|
border: 1px solid var(--bb-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.1rem 1.2rem 1.4rem;
|
||||||
|
background: #171b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-reply-title {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-thread-reply-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.bb-post-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--bb-border);
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bb-forum-row {
|
.bb-forum-row {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
import { Button, Container, Form } from 'react-bootstrap'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { createPost, getThread, listPostsByThread } from '../api/client'
|
import { createPost, getThread, listPostsByThread } from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
@@ -15,6 +15,7 @@ export default function ThreadView() {
|
|||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const replyRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -43,70 +44,125 @@ export default function ThreadView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Container className="py-5">
|
<Container className="py-4">
|
||||||
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{thread && (
|
{thread && (
|
||||||
<>
|
<div className="bb-thread">
|
||||||
<div className="bb-hero mb-4">
|
<div className="bb-thread-titlebar">
|
||||||
<p className="bb-chip">{t('thread.label')}</p>
|
<h1 className="bb-thread-title">{thread.title}</h1>
|
||||||
<h2 className="mt-3">{thread.title}</h2>
|
<div className="bb-thread-meta">
|
||||||
<p className="bb-muted mb-2">{thread.body}</p>
|
<span>{t('thread.by')}</span>
|
||||||
{thread.forum && (
|
<span className="bb-thread-author">
|
||||||
<p className="bb-muted mb-0">
|
{thread.user_name || t('thread.anonymous')}
|
||||||
{t('thread.category')}{' '}
|
</span>
|
||||||
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
|
{thread.created_at && (
|
||||||
{thread.forum.name || t('thread.back_to_category')}
|
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
|
||||||
</Link>
|
)}
|
||||||
</p>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row className="g-4">
|
<div className="bb-thread-toolbar">
|
||||||
<Col lg={7}>
|
<div className="bb-thread-actions">
|
||||||
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
|
<Button className="bb-accent-button" onClick={handleJumpToReply}>
|
||||||
{posts.length === 0 && (
|
<i className="bi bi-reply-fill" aria-hidden="true" />
|
||||||
<p className="bb-muted">{t('thread.empty')}</p>
|
<span>{t('form.post_reply')}</span>
|
||||||
)}
|
</Button>
|
||||||
{posts.map((post) => (
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
|
||||||
<Card className="bb-card mb-3" key={post.id}>
|
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
|
||||||
<Card.Body>
|
</button>
|
||||||
<Card.Text>{post.body}</Card.Text>
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
|
||||||
<small className="bb-muted">
|
<i className="bi bi-wrench" aria-hidden="true" />
|
||||||
{post.author?.username || t('thread.anonymous')}
|
</button>
|
||||||
</small>
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
|
||||||
</Card.Body>
|
<i className="bi bi-gear" aria-hidden="true" />
|
||||||
</Card>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</Col>
|
<div className="bb-thread-meta-right">
|
||||||
<Col lg={5}>
|
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
|
||||||
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4>
|
<span>•</span>
|
||||||
<div className="bb-form">
|
<span>Page 1 of 1</span>
|
||||||
{!token && (
|
</div>
|
||||||
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
</div>
|
||||||
)}
|
|
||||||
<Form onSubmit={handleSubmit}>
|
<div className="bb-posts">
|
||||||
<Form.Group className="mb-3">
|
{allPosts.map((post) => {
|
||||||
<Form.Label>{t('form.message')}</Form.Label>
|
const authorName = post.author?.username
|
||||||
<Form.Control
|
|| post.user_name
|
||||||
as="textarea"
|
|| post.author_name
|
||||||
rows={5}
|
|| t('thread.anonymous')
|
||||||
placeholder={t('form.reply_placeholder')}
|
|
||||||
value={body}
|
return (
|
||||||
onChange={(event) => setBody(event.target.value)}
|
<article className="bb-post-row" key={post.id}>
|
||||||
disabled={!token || saving}
|
<aside className="bb-post-author">
|
||||||
required
|
<div className="bb-post-avatar">
|
||||||
/>
|
<i className="bi bi-person" aria-hidden="true" />
|
||||||
</Form.Group>
|
</div>
|
||||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
<div className="bb-post-author-name">{authorName}</div>
|
||||||
{saving ? t('form.posting') : t('form.post_reply')}
|
<div className="bb-post-author-meta">
|
||||||
</Button>
|
{post.isRoot ? t('thread.label') : t('thread.reply')}
|
||||||
</Form>
|
</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>
|
</div>
|
||||||
</Col>
|
</Form>
|
||||||
</Row>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user