From 24c16ed0dd1226818fd0f3ab28a98c8a23ea46b7 Mon Sep 17 00:00:00 2001 From: Micha Date: Fri, 16 Jan 2026 02:44:04 +0100 Subject: [PATCH] Unify portal thread rows and add summary API --- CHANGELOG.md | 5 + app/Http/Controllers/PortalController.php | 149 +++++++++++++++++++ app/Http/Controllers/ThreadController.php | 1 + resources/js/api/client.js | 4 + resources/js/components/PortalTopicRow.jsx | 91 ++++++++++++ resources/js/index.css | 61 ++++++-- resources/js/pages/ForumView.jsx | 74 ++-------- resources/js/pages/Home.jsx | 162 ++++++--------------- resources/js/pages/ThreadView.jsx | 17 ++- resources/lang/de.json | 1 + resources/lang/en.json | 1 + routes/api.php | 2 + 12 files changed, 380 insertions(+), 188 deletions(-) create mode 100644 app/Http/Controllers/PortalController.php create mode 100644 resources/js/components/PortalTopicRow.jsx 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} + )} + +