Restyle thread view like phpBB

This commit is contained in:
Micha
2026-01-11 01:25:16 +01:00
parent eef3262a53
commit c8d2bd508e
3 changed files with 322 additions and 59 deletions

View File

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

View File

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

View File

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