From f9de433545bb45523d17e76fbf0a292636d76b73 Mon Sep 17 00:00:00 2001 From: Micha Date: Fri, 16 Jan 2026 01:43:07 +0100 Subject: [PATCH] fixing thsu frontend views --- .gitignore | 1 + app/Http/Controllers/ForumController.php | 83 +++++++++++++++-- app/Http/Controllers/PostController.php | 1 + app/Http/Controllers/StatsController.php | 20 ++++ app/Http/Controllers/ThreadController.php | 28 +++++- app/Http/Controllers/UserController.php | 39 ++++++++ app/Models/Forum.php | 19 ++++ app/Models/Thread.php | 6 ++ app/Models/User.php | 1 + composer.json | 2 +- config/mail.php | 2 +- ..._13_000000_add_location_to_users_table.php | 22 +++++ ...10000_add_views_count_to_threads_table.php | 22 +++++ resources/js/App.jsx | 18 ++++ resources/js/api/client.js | 14 +++ resources/js/index.css | 67 ++++++++++++++ resources/js/pages/Acp.jsx | 2 +- resources/js/pages/BoardIndex.jsx | 26 +++++- resources/js/pages/ForumView.jsx | 53 ++++++++--- resources/js/pages/Home.jsx | 87 +++++++++++++++--- resources/js/pages/ThreadView.jsx | 7 +- resources/js/pages/Ucp.jsx | 46 ++++++++- resources/lang/de.json | 11 ++- resources/lang/en.json | 9 +- routes/api.php | 3 + ...Kx2w6UusSMqnYdGfPeXWxC4PLA05Q8WWExKBOL.png | Bin 0 -> 3318 bytes ...7ZIoMACuMX5NMka3Xupw7aSAJA6g5aHqBJh6QS.png | Bin 0 -> 4698 bytes 27 files changed, 538 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/StatsController.php create mode 100644 database/migrations/2026_01_13_000000_add_location_to_users_table.php create mode 100644 database/migrations/2026_01_13_010000_add_views_count_to_threads_table.php create mode 100644 storage/app/public/avatars/RMKx2w6UusSMqnYdGfPeXWxC4PLA05Q8WWExKBOL.png create mode 100644 storage/app/public/rank-badges/av7ZIoMACuMX5NMka3Xupw7aSAJA6g5aHqBJh6QS.png diff --git a/.gitignore b/.gitignore index 51d9202..01e91c1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /public/storage /storage/*.key /storage/pail +/storage/framework/views/*.php /vendor Homestead.json Homestead.yaml diff --git a/app/Http/Controllers/ForumController.php b/app/Http/Controllers/ForumController.php index 732516d..2f019ea 100644 --- a/app/Http/Controllers/ForumController.php +++ b/app/Http/Controllers/ForumController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Forum; +use App\Models\Post; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -11,7 +12,10 @@ class ForumController extends Controller { public function index(Request $request): JsonResponse { - $query = Forum::query()->withoutTrashed(); + $query = Forum::query() + ->withoutTrashed() + ->withCount(['threads', 'posts']) + ->withSum('threads', 'views_count'); $parentParam = $request->query('parent'); if (is_array($parentParam) && array_key_exists('exists', $parentParam)) { @@ -35,15 +39,24 @@ class ForumController extends Controller $forums = $query ->orderBy('position') ->orderBy('name') - ->get() - ->map(fn (Forum $forum) => $this->serializeForum($forum)); + ->get(); - return response()->json($forums); + $forumIds = $forums->pluck('id')->all(); + $lastPostByForum = $this->loadLastPostsByForum($forumIds); + + $payload = $forums->map( + fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null) + ); + + return response()->json($payload); } public function show(Forum $forum): JsonResponse { - return response()->json($this->serializeForum($forum)); + $forum->loadCount(['threads', 'posts']) + ->loadSum('threads', 'views_count'); + $lastPost = $this->loadLastPostForForum($forum->id); + return response()->json($this->serializeForum($forum, $lastPost)); } public function store(Request $request): JsonResponse @@ -83,7 +96,11 @@ class ForumController extends Controller 'position' => ($position ?? 0) + 1, ]); - return response()->json($this->serializeForum($forum), 201); + $forum->loadCount(['threads', 'posts']) + ->loadSum('threads', 'views_count'); + $lastPost = $this->loadLastPostForForum($forum->id); + + return response()->json($this->serializeForum($forum, $lastPost), 201); } public function update(Request $request, Forum $forum): JsonResponse @@ -127,7 +144,11 @@ class ForumController extends Controller $forum->save(); - return response()->json($this->serializeForum($forum)); + $forum->loadCount(['threads', 'posts']) + ->loadSum('threads', 'views_count'); + $lastPost = $this->loadLastPostForForum($forum->id); + + return response()->json($this->serializeForum($forum, $lastPost)); } public function destroy(Request $request, Forum $forum): JsonResponse @@ -180,7 +201,7 @@ class ForumController extends Controller return null; } - private function serializeForum(Forum $forum): array + private function serializeForum(Forum $forum, ?Post $lastPost): array { return [ 'id' => $forum->id, @@ -189,8 +210,54 @@ class ForumController extends Controller '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 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; + } + + private function loadLastPostForForum(int $forumId): ?Post + { + return Post::query() + ->select('posts.*') + ->join('threads', 'posts.thread_id', '=', 'threads.id') + ->where('threads.forum_id', $forumId) + ->whereNull('posts.deleted_at') + ->whereNull('threads.deleted_at') + ->orderByDesc('posts.created_at') + ->with('user') + ->first(); + } } diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index cfea5cd..4cfdddf 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -94,6 +94,7 @@ class PostController extends Controller : null, 'user_posts_count' => $post->user?->posts_count, 'user_created_at' => $post->user?->created_at?->toIso8601String(), + 'user_location' => $post->user?->location, 'user_rank_name' => $post->user?->rank?->name, 'user_rank_badge_type' => $post->user?->rank?->badge_type, 'user_rank_badge_text' => $post->user?->rank?->badge_text, diff --git a/app/Http/Controllers/StatsController.php b/app/Http/Controllers/StatsController.php new file mode 100644 index 0000000..8b58cac --- /dev/null +++ b/app/Http/Controllers/StatsController.php @@ -0,0 +1,20 @@ +json([ + 'threads' => Thread::query()->withoutTrashed()->count(), + 'posts' => Post::query()->withoutTrashed()->count(), + 'users' => User::query()->count(), + ]); + } +} diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index 89c232a..e8837e0 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -12,9 +12,13 @@ class ThreadController extends Controller { public function index(Request $request): JsonResponse { - $query = Thread::query()->withoutTrashed()->with([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), - ]); + $query = Thread::query() + ->withoutTrashed() + ->withCount('posts') + ->with([ + 'user' => fn ($query) => $query->withCount('posts')->with('rank'), + 'latestPost.user', + ]); $forumParam = $request->query('forum'); if (is_string($forumParam)) { @@ -34,9 +38,12 @@ class ThreadController extends Controller public function show(Thread $thread): JsonResponse { + $thread->increment('views_count'); + $thread->refresh(); $thread->loadMissing([ 'user' => fn ($query) => $query->withCount('posts')->with('rank'), - ]); + 'latestPost.user', + ])->loadCount('posts'); return response()->json($this->serializeThread($thread)); } @@ -62,6 +69,11 @@ class ThreadController extends Controller 'body' => $data['body'], ]); + $thread->loadMissing([ + 'user' => fn ($query) => $query->withCount('posts')->with('rank'), + 'latestPost.user', + ])->loadCount('posts'); + return response()->json($this->serializeThread($thread), 201); } @@ -99,18 +111,26 @@ class ThreadController extends Controller '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_location' => $thread->user?->location, '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_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(), ]; diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 6f55045..8786871 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -22,6 +22,7 @@ class UserController extends Controller 'name' => $user->name, 'email' => $user->email, 'avatar_url' => $this->resolveAvatarUrl($user), + 'location' => $user->location, 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, @@ -45,6 +46,7 @@ class UserController extends Controller 'name' => $user->name, 'email' => $user->email, 'avatar_url' => $this->resolveAvatarUrl($user), + 'location' => $user->location, 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, @@ -59,6 +61,7 @@ class UserController extends Controller 'id' => $user->id, 'name' => $user->name, 'avatar_url' => $this->resolveAvatarUrl($user), + 'location' => $user->location, 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, @@ -67,6 +70,42 @@ class UserController extends Controller ]); } + public function updateMe(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + $data = $request->validate([ + 'location' => ['nullable', 'string', 'max:255'], + ]); + + $location = isset($data['location']) ? trim($data['location']) : null; + if ($location === '') { + $location = null; + } + + $user->forceFill([ + 'location' => $location, + ])->save(); + + $user->loadMissing('rank'); + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $this->resolveAvatarUrl($user), + 'location' => $user->location, + 'rank' => $user->rank ? [ + 'id' => $user->rank->id, + 'name' => $user->rank->name, + ] : null, + 'roles' => $user->roles()->pluck('name')->values(), + ]); + } + public function updateRank(Request $request, User $user): JsonResponse { $actor = $request->user(); diff --git a/app/Models/Forum.php b/app/Models/Forum.php index c4135d7..f1dc97c 100644 --- a/app/Models/Forum.php +++ b/app/Models/Forum.php @@ -4,6 +4,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -60,4 +63,20 @@ class Forum extends Model { return $this->hasMany(Thread::class); } + + public function posts(): HasManyThrough + { + return $this->hasManyThrough(Post::class, Thread::class, 'forum_id', 'thread_id'); + } + + public function latestThread(): HasOne + { + return $this->hasOne(Thread::class)->latestOfMany(); + } + + public function latestPost(): HasOneThrough + { + return $this->hasOneThrough(Post::class, Thread::class, 'forum_id', 'thread_id') + ->latestOfMany(); + } } diff --git a/app/Models/Thread.php b/app/Models/Thread.php index 90d1048..2c78cea 100644 --- a/app/Models/Thread.php +++ b/app/Models/Thread.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -56,4 +57,9 @@ class Thread extends Model { return $this->hasMany(Post::class); } + + public function latestPost(): HasOne + { + return $this->hasOne(Post::class)->latestOfMany(); + } } diff --git a/app/Models/User.php b/app/Models/User.php index a80c99b..77c4276 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -67,6 +67,7 @@ class User extends Authenticatable implements MustVerifyEmail 'name', 'name_canonical', 'avatar_path', + 'location', 'rank_id', 'email', 'password', diff --git a/composer.json b/composer.json index 6412fae..ff38159 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.4", "laravel/fortify": "*", "laravel/framework": "^12.0", "laravel/sanctum": "*", diff --git a/config/mail.php b/config/mail.php index f9c2a43..f36c3bb 100644 --- a/config/mail.php +++ b/config/mail.php @@ -48,7 +48,7 @@ return [ 'timeout' => null, 'local_domain' => env( 'MAIL_EHLO_DOMAIN', - parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST) + parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST) ), ], diff --git a/database/migrations/2026_01_13_000000_add_location_to_users_table.php b/database/migrations/2026_01_13_000000_add_location_to_users_table.php new file mode 100644 index 0000000..c8a1b5d --- /dev/null +++ b/database/migrations/2026_01_13_000000_add_location_to_users_table.php @@ -0,0 +1,22 @@ +string('location')->nullable()->after('avatar_path'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('location'); + }); + } +}; diff --git a/database/migrations/2026_01_13_010000_add_views_count_to_threads_table.php b/database/migrations/2026_01_13_010000_add_views_count_to_threads_table.php new file mode 100644 index 0000000..9b4e4ec --- /dev/null +++ b/database/migrations/2026_01_13_010000_add_views_count_to_threads_table.php @@ -0,0 +1,22 @@ +unsignedInteger('views_count')->default(0)->after('body'); + }); + } + + public function down(): void + { + Schema::table('threads', function (Blueprint $table) { + $table->dropColumn('views_count'); + }); + } +}; diff --git a/resources/js/App.jsx b/resources/js/App.jsx index fcf8e6d..45ff43d 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -114,6 +114,24 @@ function PortalHeader({ return } + if (location.pathname.startsWith('/ucp')) { + setCrumbs([ + { ...base[0] }, + { ...base[1] }, + { label: t('portal.user_control_panel'), to: '/ucp', current: true }, + ]) + return + } + + if (location.pathname.startsWith('/profile/')) { + setCrumbs([ + { ...base[0] }, + { ...base[1] }, + { label: t('portal.user_profile'), to: location.pathname, current: true }, + ]) + return + } + setCrumbs([{ ...base[0] }, { ...base[1], current: true }]) } diff --git a/resources/js/api/client.js b/resources/js/api/client.js index 39cffba..ed5e390 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -74,6 +74,16 @@ export async function getCurrentUser() { return apiFetch('/user/me') } +export async function updateCurrentUser(payload) { + return apiFetch('/user/me', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + body: JSON.stringify(payload), + }) +} + export async function uploadAvatar(file) { const body = new FormData() body.append('file', file) @@ -91,6 +101,10 @@ export async function fetchVersion() { return apiFetch('/version') } +export async function fetchStats() { + return apiFetch('/stats') +} + export async function fetchSetting(key) { // TODO: Prefer fetchSettings() when multiple settings are needed. const cacheBust = Date.now() diff --git a/resources/js/index.css b/resources/js/index.css index 74f633c..8eeacc7 100644 --- a/resources/js/index.css +++ b/resources/js/index.css @@ -1181,6 +1181,33 @@ a { font-size: 0.9rem; } +.bb-board-last { + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: center; +} + +.bb-board-last-by { + color: var(--bb-ink-muted); + font-weight: 600; +} + +.bb-board-last-link { + color: var(--bb-accent, #f29b3f); + font-weight: 600; +} + +.bb-board-last-link:hover { + color: var(--bb-accent, #f29b3f); + text-decoration: none; +} + +.bb-board-last-date { + color: var(--bb-ink-muted); + font-weight: 500; +} + .bb-board-title { display: flex; align-items: flex-start; @@ -1425,6 +1452,16 @@ a { color: var(--bb-ink-muted); } +.bb-portal-topic-author { + color: var(--bb-accent, #f29b3f); + font-weight: 600; +} + +.bb-portal-topic-author:hover { + color: var(--bb-accent, #f29b3f); + text-decoration: none; +} + .bb-portal-topic-forum-link { color: var(--bb-accent, #f29b3f); font-weight: 600; @@ -1441,6 +1478,36 @@ a { font-weight: 600; } +.bb-portal-last { + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: flex-start; +} + +.bb-portal-last-by { + color: var(--bb-ink-muted); + font-weight: 600; +} + +.bb-portal-last-link { + color: var(--bb-accent, #f29b3f); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.bb-portal-last-link:hover { + color: var(--bb-accent, #f29b3f); + text-decoration: none; +} + +.bb-portal-last-date { + color: var(--bb-ink-muted); + font-weight: 500; +} + .bb-portal-user-card { display: flex; flex-direction: column; diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index 1a641e3..7c345dd 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -1061,7 +1061,7 @@ export default function Acp({ isAdmin }) {
{node.name}
-
{node.description || t('forum.no_description')}
+
{node.description || ''}
diff --git a/resources/js/pages/BoardIndex.jsx b/resources/js/pages/BoardIndex.jsx index 6177cb6..1a89491 100644 --- a/resources/js/pages/BoardIndex.jsx +++ b/resources/js/pages/BoardIndex.jsx @@ -113,7 +113,7 @@ export default function BoardIndex() { {node.name} -
{node.description || t('forum.no_description')}
+
{node.description || ''}
{node.children?.length > 0 && (
{t('forum.children')}:{' '} @@ -130,10 +130,28 @@ export default function BoardIndex() {
-
-
+
{node.threads_count ?? 0}
+
{node.views_count ?? 0}
- {t('thread.no_replies')} + {node.last_post_at ? ( +
+ + {t('thread.by')}{' '} + {node.last_post_user_id ? ( + + {node.last_post_user_name || t('thread.anonymous')} + + ) : ( + {node.last_post_user_name || t('thread.anonymous')} + )} + + + {node.last_post_at.slice(0, 10)} + +
+ ) : ( + {t('thread.no_replies')} + )}
)) diff --git a/resources/js/pages/ForumView.jsx b/resources/js/pages/ForumView.jsx index 493048e..8b2ea78 100644 --- a/resources/js/pages/ForumView.jsx +++ b/resources/js/pages/ForumView.jsx @@ -31,14 +31,30 @@ export default function ForumView() { {node.name} -
{node.description || t('forum.no_description')}
+
{node.description || ''}
-
-
+
{node.threads_count ?? 0}
+
{node.views_count ?? 0}
- {t('thread.no_replies')} + {node.last_post_at ? ( +
+ + {t('thread.by')}{' '} + {node.last_post_user_id ? ( + + {node.last_post_user_name || t('thread.anonymous')} + + ) : ( + {node.last_post_user_name || t('thread.anonymous')} + )} + + {node.last_post_at.slice(0, 10)} +
+ ) : ( + {t('thread.no_replies')} + )}
)) @@ -190,18 +206,33 @@ export default function ForumView() { -
0
-
+
+ {thread.posts_count ?? 0} +
+
+ {thread.views_count ?? 0} +
{t('thread.by')}{' '} - - {thread.user_name || t('thread.anonymous')} - + {thread.last_post_user_id ? ( + + {thread.last_post_user_name || t('thread.anonymous')} + + ) : ( + + {thread.last_post_user_name || t('thread.anonymous')} + + )} - {thread.created_at && ( - {thread.created_at.slice(0, 10)} + {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 8750a4f..29613a1 100644 --- a/resources/js/pages/Home.jsx +++ b/resources/js/pages/Home.jsx @@ -1,16 +1,18 @@ import { useEffect, useMemo, useState } from 'react' -import { Badge, Container } from 'react-bootstrap' +import { Container } from 'react-bootstrap' import { Link } from 'react-router-dom' -import { getCurrentUser, listAllForums, listThreads } from '../api/client' +import { fetchStats, getCurrentUser, listAllForums, listThreads } from '../api/client' import { useTranslation } from 'react-i18next' import { useAuth } from '../context/AuthContext' export default function Home() { const [forums, setForums] = useState([]) const [threads, setThreads] = useState([]) + const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 }) const [error, setError] = useState('') const [loadingForums, setLoadingForums] = useState(true) const [loadingThreads, setLoadingThreads] = useState(true) + const [loadingStats, setLoadingStats] = useState(true) const [profile, setProfile] = useState(null) const { token, roles, email } = useAuth() const { t } = useTranslation() @@ -29,6 +31,21 @@ export default function Home() { .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) @@ -110,6 +127,19 @@ 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('/') @@ -138,7 +168,7 @@ export default function Home() { {node.name} -
{node.description || t('forum.no_description')}
+
{node.description || ''}
@@ -165,11 +195,15 @@ export default function Home() {
{t('portal.stats')}
{t('portal.stat_threads')} - {threads.length} + {loadingStats ? '—' : stats.threads}
- {t('portal.stat_forums')} - {forums.length} + {t('portal.stat_users')} + {loadingStats ? '—' : stats.users} +
+
+ {t('portal.stat_posts')} + {loadingStats ? '—' : stats.posts}
@@ -201,9 +235,18 @@ export default function Home() {
{t('thread.by')} - - {thread.user_name || t('thread.anonymous')} - + {thread.user_id ? ( + + {thread.user_name || t('thread.anonymous')} + + ) : ( + + {thread.user_name || t('thread.anonymous')} + + )} {t('portal.forum_label')}{' '} {resolveForumId(thread) ? ( @@ -220,10 +263,30 @@ export default function Home() {
-
0
-
+
{thread.posts_count ?? 0}
+
{thread.views_count ?? 0}
- {thread.created_at?.slice(0, 10) || '—'} +
+ + {t('thread.by')}{' '} + {thread.last_post_user_id ? ( + + {thread.last_post_user_name || t('thread.anonymous')} +