diff --git a/CHANGELOG.md b/CHANGELOG.md
index 916dbcd..f8d2efb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/app/Http/Controllers/PortalController.php b/app/Http/Controllers/PortalController.php
new file mode 100644
index 0000000..0260eb2
--- /dev/null
+++ b/app/Http/Controllers/PortalController.php
@@ -0,0 +1,149 @@
+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;
+ }
+}
diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php
index e8837e0..762f68f 100644
--- a/app/Http/Controllers/ThreadController.php
+++ b/app/Http/Controllers/ThreadController.php
@@ -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,
diff --git a/resources/js/api/client.js b/resources/js/api/client.js
index ed5e390..b50d796 100644
--- a/resources/js/api/client.js
+++ b/resources/js/api/client.js
@@ -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()
diff --git a/resources/js/components/PortalTopicRow.jsx b/resources/js/components/PortalTopicRow.jsx
new file mode 100644
index 0000000..c46aca7
--- /dev/null
+++ b/resources/js/components/PortalTopicRow.jsx
@@ -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 (
+
+
+
+
+
+
+
+ {thread.title}
+
+
+
+ {t('portal.posted_by')}
+ {thread.user_id ? (
+
+ {authorName}
+
+ ) : (
+ {authorName}
+ )}
+ »
+ {formatDateTime(thread.created_at)}
+
+ {showForum && (
+
+ {t('portal.forum_label')}
+
+ {forumId ? (
+
+ {forumName}
+
+ ) : (
+ forumName
+ )}
+
+
+ )}
+
+
+
+
{thread.posts_count ?? 0}
+
{thread.views_count ?? 0}
+
+
+
+ {t('thread.by')}{' '}
+ {thread.last_post_user_id ? (
+
+ {lastAuthorName}
+
+ ) : (
+ {lastAuthorName}
+ )}
+
+
+
+
+
+ {formatDateTime(thread.last_post_at || thread.created_at)}
+
+
+
+
+ )
+}
diff --git a/resources/js/index.css b/resources/js/index.css
index 8eeacc7..0a12bd0 100644
--- a/resources/js/index.css
+++ b/resources/js/index.css
@@ -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;
}
diff --git a/resources/js/pages/ForumView.jsx b/resources/js/pages/ForumView.jsx
index 8b2ea78..9283056 100644
--- a/resources/js/pages/ForumView.jsx
+++ b/resources/js/pages/ForumView.jsx
@@ -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() {
{!token && {t('forum.login_hint')}
}
-
-
-
{t('forum.threads')}
-
{t('thread.replies')}
-
{t('thread.views')}
-
{t('thread.last_post')}
+
+
+ {t('portal.topic')}
+ {t('thread.replies')}
+ {t('thread.views')}
+ {t('thread.last_post')}
{threads.length === 0 && (
{t('forum.empty_threads')}
)}
{threads.map((thread) => (
-
-
-
-
-
-
-
-
{thread.title}
-
-
- {t('thread.by')}
-
- {thread.user_name || t('thread.anonymous')}
-
- {thread.created_at && (
-
- {thread.created_at.slice(0, 10)}
-
- )}
-
-
-
-
-
- {thread.posts_count ?? 0}
-
-
- {thread.views_count ?? 0}
-
-
-
-
- {t('thread.by')}{' '}
- {thread.last_post_user_id ? (
-
- {thread.last_post_user_name || t('thread.anonymous')}
-
- ) : (
-
- {thread.last_post_user_name || t('thread.anonymous')}
-
- )}
-
- {thread.last_post_at && (
-
- {thread.last_post_at.slice(0, 10)}
-
- )}
-
-
-
+
))}
>
diff --git a/resources/js/pages/Home.jsx b/resources/js/pages/Home.jsx
index 29613a1..af3df88 100644
--- a/resources/js/pages/Home.jsx
+++ b/resources/js/pages/Home.jsx
@@ -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() {
{t('thread.last_post')}
{recentThreads.map((thread) => (
-
-
-
-
-
-
-
- {thread.title}
-
-
- {t('thread.by')}
- {thread.user_id ? (
-
- {thread.user_name || t('thread.anonymous')}
-
- ) : (
-
- {thread.user_name || t('thread.anonymous')}
-
- )}
-
- {t('portal.forum_label')}{' '}
- {resolveForumId(thread) ? (
-
- {resolveForumName(thread)}
-
- ) : (
- resolveForumName(thread)
- )}
-
-
-
-
-
{thread.posts_count ?? 0}
-
{thread.views_count ?? 0}
-
-
-
- {t('thread.by')}{' '}
- {thread.last_post_user_id ? (
-
- {thread.last_post_user_name || t('thread.anonymous')}
-
-
- ) : (
-
- {thread.last_post_user_name || t('thread.anonymous')}
-
- )}
-
-
- {formatDateTime(thread.last_post_at || thread.created_at)}
-
-
-
-
+
))}
)}
@@ -299,14 +217,22 @@ export default function Home() {
{t('portal.user_menu')}
-
+
{profile?.avatar_url ? (

) : (
)}
+
+
+ {profile?.id ? (
+
+ {profile?.name || email || 'User'}
+
+ ) : (
+ profile?.name || email || 'User'
+ )}
-
{profile?.name || email || 'User'}
{roleLabel}
diff --git a/resources/js/pages/ThreadView.jsx b/resources/js/pages/ThreadView.jsx
index 69664d5..9738024 100644
--- a/resources/js/pages/ThreadView.jsx
+++ b/resources/js/pages/ThreadView.jsx
@@ -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 (
-
+
{post.body}
diff --git a/resources/lang/de.json b/resources/lang/de.json
index d9f1df1..7d4b672 100644
--- a/resources/lang/de.json
+++ b/resources/lang/de.json
@@ -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ü",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index d84a0e9..53eaa73 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -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",
diff --git a/routes/api.php b/routes/api.php
index ab4d7b2..d01ae49 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -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');