Unify portal thread rows and add summary API
This commit is contained in:
@@ -105,6 +105,10 @@ export async function fetchStats() {
|
||||
return apiFetch('/stats')
|
||||
}
|
||||
|
||||
export async function fetchPortalSummary() {
|
||||
return apiFetch('/portal/summary')
|
||||
}
|
||||
|
||||
export async function fetchSetting(key) {
|
||||
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
||||
const cacheBust = Date.now()
|
||||
|
||||
91
resources/js/components/PortalTopicRow.jsx
Normal file
91
resources/js/components/PortalTopicRow.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) {
|
||||
const { t } = useTranslation()
|
||||
const authorName = thread.user_name || t('thread.anonymous')
|
||||
const lastAuthorName = thread.last_post_user_name || authorName
|
||||
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
|
||||
|
||||
const formatDateTime = (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())
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bb-portal-topic-row">
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<div className="bb-portal-topic-meta-line">
|
||||
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
||||
{thread.user_id ? (
|
||||
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-portal-topic-author">{authorName}</span>
|
||||
)}
|
||||
<span className="bb-portal-topic-meta-sep">»</span>
|
||||
<span className="bb-portal-topic-meta-date">{formatDateTime(thread.created_at)}</span>
|
||||
</div>
|
||||
{showForum && (
|
||||
<div className="bb-portal-topic-meta-line">
|
||||
<span className="bb-portal-topic-meta-label">{t('portal.forum_label')}</span>
|
||||
<span className="bb-portal-topic-forum">
|
||||
{forumId ? (
|
||||
<Link to={`/forum/${forumId}`} className="bb-portal-topic-forum-link">
|
||||
{forumName}
|
||||
</Link>
|
||||
) : (
|
||||
forumName
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
|
||||
<div className="bb-portal-last">
|
||||
<span className="bb-portal-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{thread.last_post_user_id ? (
|
||||
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
|
||||
{lastAuthorName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-portal-last-user">{lastAuthorName}</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/thread/${thread.id}${lastPostAnchor}`}
|
||||
className="bb-portal-last-jump ms-2"
|
||||
aria-label={t('thread.view')}
|
||||
>
|
||||
<i className="bi bi-eye" aria-hidden="true" />
|
||||
</Link>
|
||||
</span>
|
||||
<span className="bb-portal-last-date">
|
||||
{formatDateTime(thread.last_post_at || thread.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1439,13 +1439,34 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta {
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-label {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-sep {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-date {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bb-portal-topic-forum {
|
||||
@@ -1478,6 +1499,10 @@ a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-cell--last {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bb-portal-last {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1490,19 +1515,28 @@ a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-last-link {
|
||||
.bb-portal-last-user {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.bb-portal-last-link:hover {
|
||||
.bb-portal-last-user:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-last-jump {
|
||||
color: var(--bb-ink-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-portal-last-jump:hover {
|
||||
color: var(--bb-ink-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-last-date {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 500;
|
||||
@@ -1537,13 +1571,22 @@ a {
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.bb-portal-user-name-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-portal-user-name-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-user-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||
import PortalTopicRow from '../components/PortalTopicRow'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -172,71 +173,24 @@ export default function ForumView() {
|
||||
</div>
|
||||
</div>
|
||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||
<div className="bb-topic-table">
|
||||
<div className="bb-topic-header">
|
||||
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
|
||||
<div className="bb-portal-topic-table">
|
||||
<div className="bb-portal-topic-header">
|
||||
<span>{t('portal.topic')}</span>
|
||||
<span>{t('thread.replies')}</span>
|
||||
<span>{t('thread.views')}</span>
|
||||
<span>{t('thread.last_post')}</span>
|
||||
</div>
|
||||
{threads.length === 0 && (
|
||||
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<div className="bb-topic-row" key={thread.id}>
|
||||
<div className="bb-topic-cell bb-topic-cell--title">
|
||||
<div className="bb-topic-title">
|
||||
<span className="bb-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left" />
|
||||
</span>
|
||||
<div className="bb-topic-text">
|
||||
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
|
||||
<div className="bb-topic-meta">
|
||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||
<span>{t('thread.by')}</span>
|
||||
<span className="bb-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-topic-date">
|
||||
{thread.created_at.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">
|
||||
{thread.posts_count ?? 0}
|
||||
</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">
|
||||
{thread.views_count ?? 0}
|
||||
</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">
|
||||
<div className="bb-topic-last">
|
||||
<span className="bb-topic-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{thread.last_post_user_id ? (
|
||||
<Link
|
||||
to={`/profile/${thread.last_post_user_id}`}
|
||||
className="bb-topic-author"
|
||||
>
|
||||
{thread.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-topic-author">
|
||||
{thread.last_post_user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{thread.last_post_at && (
|
||||
<span className="bb-topic-date">
|
||||
{thread.last_post_at.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalTopicRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
forumName={forum?.name || t('portal.unknown_forum')}
|
||||
forumId={forum?.id}
|
||||
showForum={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchStats, getCurrentUser, listAllForums, listThreads } from '../api/client'
|
||||
import { fetchPortalSummary } from '../api/client'
|
||||
import PortalTopicRow from '../components/PortalTopicRow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
@@ -18,48 +19,37 @@ export default function Home() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingForums(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingThreads(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
.then((data) => {
|
||||
setStats({
|
||||
threads: data?.threads ?? 0,
|
||||
posts: data?.posts ?? 0,
|
||||
users: data?.users ?? 0,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
setStats({ threads: 0, posts: 0, users: 0 })
|
||||
})
|
||||
.finally(() => setLoadingStats(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setProfile(null)
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
setLoadingForums(true)
|
||||
setLoadingThreads(true)
|
||||
setLoadingStats(true)
|
||||
setError('')
|
||||
|
||||
getCurrentUser()
|
||||
fetchPortalSummary()
|
||||
.then((data) => {
|
||||
if (!active) return
|
||||
setProfile(data)
|
||||
setForums(data?.forums || [])
|
||||
setThreads(data?.threads || [])
|
||||
setStats({
|
||||
threads: data?.stats?.threads ?? 0,
|
||||
posts: data?.stats?.posts ?? 0,
|
||||
users: data?.stats?.users ?? 0,
|
||||
})
|
||||
setProfile(data?.profile || null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setProfile(null)
|
||||
.catch((err) => {
|
||||
if (!active) return
|
||||
setError(err.message)
|
||||
setForums([])
|
||||
setThreads([])
|
||||
setStats({ threads: 0, posts: 0, users: 0 })
|
||||
setProfile(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoadingForums(false)
|
||||
setLoadingThreads(false)
|
||||
setLoadingStats(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -127,19 +117,6 @@ export default function Home() {
|
||||
return t('portal.user_role_member')
|
||||
}, [roles, t])
|
||||
|
||||
const formatDateTime = (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())
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
const resolveForumName = (thread) => {
|
||||
if (!thread?.forum) return t('portal.unknown_forum')
|
||||
const parts = thread.forum.split('/')
|
||||
@@ -224,71 +201,12 @@ export default function Home() {
|
||||
<span>{t('thread.last_post')}</span>
|
||||
</div>
|
||||
{recentThreads.map((thread) => (
|
||||
<div className="bb-portal-topic-row" key={thread.id}>
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
{thread.user_id ? (
|
||||
<Link
|
||||
to={`/profile/${thread.user_id}`}
|
||||
className="bb-portal-topic-author"
|
||||
>
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-portal-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
<span className="bb-portal-topic-forum">
|
||||
{t('portal.forum_label')}{' '}
|
||||
{resolveForumId(thread) ? (
|
||||
<Link
|
||||
to={`/forum/${resolveForumId(thread)}`}
|
||||
className="bb-portal-topic-forum-link"
|
||||
>
|
||||
{resolveForumName(thread)}
|
||||
</Link>
|
||||
) : (
|
||||
resolveForumName(thread)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell">
|
||||
<div className="bb-portal-last">
|
||||
<span className="bb-portal-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{thread.last_post_user_id ? (
|
||||
<Link
|
||||
to={`/profile/${thread.last_post_user_id}`}
|
||||
className="bb-portal-last-link"
|
||||
>
|
||||
{thread.last_post_user_name || t('thread.anonymous')}
|
||||
<i className="bi bi-box-arrow-up-right" aria-hidden="true" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-portal-last-link">
|
||||
{thread.last_post_user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="bb-portal-last-date">
|
||||
{formatDateTime(thread.last_post_at || thread.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalTopicRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
forumName={resolveForumName(thread)}
|
||||
forumId={resolveForumId(thread)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -299,14 +217,22 @@ export default function Home() {
|
||||
<div className="bb-portal-card">
|
||||
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
||||
<div className="bb-portal-user-card">
|
||||
<div className="bb-portal-user-avatar">
|
||||
<Link to="/ucp" className="bb-portal-user-avatar">
|
||||
{profile?.avatar_url ? (
|
||||
<img src={profile.avatar_url} alt="" />
|
||||
) : (
|
||||
<i className="bi bi-person" aria-hidden="true" />
|
||||
)}
|
||||
</Link>
|
||||
<div className="bb-portal-user-name">
|
||||
{profile?.id ? (
|
||||
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
|
||||
{profile?.name || email || 'User'}
|
||||
</Link>
|
||||
) : (
|
||||
profile?.name || email || 'User'
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-portal-user-name">{profile?.name || email || 'User'}</div>
|
||||
<div className="bb-portal-user-role">{roleLabel}</div>
|
||||
</div>
|
||||
<ul className="bb-portal-list">
|
||||
|
||||
@@ -28,6 +28,18 @@ export default function ThreadView() {
|
||||
.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' })
|
||||
}
|
||||
}, [thread, posts])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -136,7 +148,7 @@ export default function ThreadView() {
|
||||
const postNumber = index + 1
|
||||
|
||||
return (
|
||||
<article className="bb-post-row" key={post.id}>
|
||||
<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 ? (
|
||||
@@ -222,6 +234,9 @@ export default function ThreadView() {
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user