Unify portal thread rows and add summary API

This commit is contained in:
Micha
2026-01-16 02:44:04 +01:00
parent f9de433545
commit 24c16ed0dd
12 changed files with 380 additions and 188 deletions

View File

@@ -11,6 +11,11 @@
- Added ACP users search and improved sorting indicators.
- Added thread sidebar fields for posts count, registration date, and topic header.
- Linked header logo to the portal and fixed ACP breadcrumbs.
- Added profile location field with UCP editing and post sidebar display.
- Added per-thread replies and views counts, including view tracking.
- Added per-forum topics/views counts plus last-post details in board listings.
- Added portal summary API to load forums, stats, and recent posts in one request.
- Unified portal and forum thread list row styling with shared component.
## 2026-01-11
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PortalController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$forums = Forum::query()
->withoutTrashed()
->withCount(['threads', 'posts'])
->withSum('threads', 'views_count')
->orderBy('position')
->orderBy('name')
->get();
$forumIds = $forums->pluck('id')->all();
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
$forumPayload = $forums->map(
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
);
$threads = Thread::query()
->withoutTrashed()
->withCount('posts')
->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
])
->latest('created_at')
->limit(12)
->get()
->map(fn (Thread $thread) => $this->serializeThread($thread));
$stats = [
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
];
$user = auth('sanctum')->user();
return response()->json([
'forums' => $forumPayload,
'threads' => $threads,
'stats' => $stats,
'profile' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatar_path ? Storage::url($user->avatar_path) : null,
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
] : null,
]);
}
private function serializeForum(Forum $forum, ?Post $lastPost): array
{
return [
'id' => $forum->id,
'name' => $forum->name,
'description' => $forum->description,
'type' => $forum->type,
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
'position' => $forum->position,
'threads_count' => $forum->threads_count ?? 0,
'posts_count' => $forum->posts_count ?? 0,
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name,
'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_at?->toIso8601String(),
];
}
private function serializeThread(Thread $thread): array
{
return [
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => $thread->posts_count ?? 0,
'views_count' => $thread->views_count ?? 0,
'user_name' => $thread->user?->name,
'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path)
: null,
'user_posts_count' => $thread->user?->posts_count,
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'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_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->user?->name,
'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(),
];
}
private function loadLastPostsByForum(array $forumIds): array
{
if (empty($forumIds)) {
return [];
}
$posts = Post::query()
->select('posts.*', 'threads.forum_id as forum_id')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->whereIn('threads.forum_id', $forumIds)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with('user')
->get();
$byForum = [];
foreach ($posts as $post) {
$forumId = (int) ($post->forum_id ?? 0);
if ($forumId && !array_key_exists($forumId, $byForum)) {
$byForum[$forumId] = $post;
}
}
return $byForum;
}
}

View File

@@ -128,6 +128,7 @@ class ThreadController extends Controller
: null,
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->user?->name,

View File

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

View 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>
)
}

View File

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

View File

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

View File

@@ -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,
})
.catch(() => {
if (active) setProfile(null)
setProfile(data?.profile || 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">

View File

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

View File

@@ -159,6 +159,7 @@
"portal.latest_posts": "Aktuelle Beiträge",
"portal.empty_posts": "Noch keine Beiträge.",
"portal.topic": "Themen",
"portal.posted_by": "Verfasst von",
"portal.forum_label": "Forum:",
"portal.unknown_forum": "Unbekannt",
"portal.user_menu": "Benutzer-Menü",

View File

@@ -159,6 +159,7 @@
"portal.latest_posts": "Latest posts",
"portal.empty_posts": "No posts yet.",
"portal.topic": "Topics",
"portal.posted_by": "Posted by",
"portal.forum_label": "Forum:",
"portal.unknown_forum": "Unknown",
"portal.user_menu": "User menu",

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ForumController;
use App\Http\Controllers\I18nController;
use App\Http\Controllers\PortalController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SettingController;
use App\Http\Controllers\StatsController;
@@ -25,6 +26,7 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
Route::get('/version', VersionController::class);
Route::get('/portal/summary', PortalController::class);
Route::get('/stats', StatsController::class);
Route::get('/settings', [SettingController::class, 'index']);
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');