fixing thsu frontend views

This commit is contained in:
Micha
2026-01-16 01:43:07 +01:00
parent fd29b928d8
commit f9de433545
27 changed files with 538 additions and 51 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/framework/views/*.php
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Forum; use App\Models\Forum;
use App\Models\Post;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -11,7 +12,10 @@ class ForumController extends Controller
{ {
public function index(Request $request): JsonResponse 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'); $parentParam = $request->query('parent');
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) { if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
@@ -35,15 +39,24 @@ class ForumController extends Controller
$forums = $query $forums = $query
->orderBy('position') ->orderBy('position')
->orderBy('name') ->orderBy('name')
->get() ->get();
->map(fn (Forum $forum) => $this->serializeForum($forum));
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 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 public function store(Request $request): JsonResponse
@@ -83,7 +96,11 @@ class ForumController extends Controller
'position' => ($position ?? 0) + 1, '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 public function update(Request $request, Forum $forum): JsonResponse
@@ -127,7 +144,11 @@ class ForumController extends Controller
$forum->save(); $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 public function destroy(Request $request, Forum $forum): JsonResponse
@@ -180,7 +201,7 @@ class ForumController extends Controller
return null; return null;
} }
private function serializeForum(Forum $forum): array private function serializeForum(Forum $forum, ?Post $lastPost): array
{ {
return [ return [
'id' => $forum->id, 'id' => $forum->id,
@@ -189,8 +210,54 @@ class ForumController extends Controller
'type' => $forum->type, 'type' => $forum->type,
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null, 'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
'position' => $forum->position, '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(), 'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_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();
}
} }

View File

@@ -94,6 +94,7 @@ class PostController extends Controller
: null, : null,
'user_posts_count' => $post->user?->posts_count, 'user_posts_count' => $post->user?->posts_count,
'user_created_at' => $post->user?->created_at?->toIso8601String(), 'user_created_at' => $post->user?->created_at?->toIso8601String(),
'user_location' => $post->user?->location,
'user_rank_name' => $post->user?->rank?->name, 'user_rank_name' => $post->user?->rank?->name,
'user_rank_badge_type' => $post->user?->rank?->badge_type, 'user_rank_badge_type' => $post->user?->rank?->badge_type,
'user_rank_badge_text' => $post->user?->rank?->badge_text, 'user_rank_badge_text' => $post->user?->rank?->badge_text,

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\JsonResponse;
class StatsController extends Controller
{
public function __invoke(): JsonResponse
{
return response()->json([
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
]);
}
}

View File

@@ -12,9 +12,13 @@ class ThreadController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Thread::query()->withoutTrashed()->with([ $query = Thread::query()
'user' => fn ($query) => $query->withCount('posts')->with('rank'), ->withoutTrashed()
]); ->withCount('posts')
->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
]);
$forumParam = $request->query('forum'); $forumParam = $request->query('forum');
if (is_string($forumParam)) { if (is_string($forumParam)) {
@@ -34,9 +38,12 @@ class ThreadController extends Controller
public function show(Thread $thread): JsonResponse public function show(Thread $thread): JsonResponse
{ {
$thread->increment('views_count');
$thread->refresh();
$thread->loadMissing([ $thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'), 'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]); 'latestPost.user',
])->loadCount('posts');
return response()->json($this->serializeThread($thread)); return response()->json($this->serializeThread($thread));
} }
@@ -62,6 +69,11 @@ class ThreadController extends Controller
'body' => $data['body'], 'body' => $data['body'],
]); ]);
$thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
])->loadCount('posts');
return response()->json($this->serializeThread($thread), 201); return response()->json($this->serializeThread($thread), 201);
} }
@@ -99,18 +111,26 @@ class ThreadController extends Controller
'body' => $thread->body, 'body' => $thread->body,
'forum' => "/api/forums/{$thread->forum_id}", 'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id, 'user_id' => $thread->user_id,
'posts_count' => $thread->posts_count ?? 0,
'views_count' => $thread->views_count ?? 0,
'user_name' => $thread->user?->name, 'user_name' => $thread->user?->name,
'user_avatar_url' => $thread->user?->avatar_path 'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path) ? Storage::url($thread->user->avatar_path)
: null, : null,
'user_posts_count' => $thread->user?->posts_count, 'user_posts_count' => $thread->user?->posts_count,
'user_created_at' => $thread->user?->created_at?->toIso8601String(), 'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_location' => $thread->user?->location,
'user_rank_name' => $thread->user?->rank?->name, 'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type, 'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text, 'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path 'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path) ? Storage::url($thread->user->rank->badge_image_path)
: null, : 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(), 'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(),
]; ];

View File

@@ -22,6 +22,7 @@ class UserController extends Controller
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user), 'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
@@ -45,6 +46,7 @@ class UserController extends Controller
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user), 'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
@@ -59,6 +61,7 @@ class UserController extends Controller
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'avatar_url' => $this->resolveAvatarUrl($user), 'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, '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 public function updateRank(Request $request, User $user): JsonResponse
{ {
$actor = $request->user(); $actor = $request->user();

View File

@@ -4,6 +4,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; 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\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -60,4 +63,20 @@ class Forum extends Model
{ {
return $this->hasMany(Thread::class); 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();
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -56,4 +57,9 @@ class Thread extends Model
{ {
return $this->hasMany(Post::class); return $this->hasMany(Post::class);
} }
public function latestPost(): HasOne
{
return $this->hasOne(Post::class)->latestOfMany();
}
} }

View File

@@ -67,6 +67,7 @@ class User extends Authenticatable implements MustVerifyEmail
'name', 'name',
'name_canonical', 'name_canonical',
'avatar_path', 'avatar_path',
'location',
'rank_id', 'rank_id',
'email', 'email',
'password', 'password',

View File

@@ -6,7 +6,7 @@
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.4",
"laravel/fortify": "*", "laravel/fortify": "*",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "*", "laravel/sanctum": "*",

View File

@@ -48,7 +48,7 @@ return [
'timeout' => null, 'timeout' => null,
'local_domain' => env( 'local_domain' => env(
'MAIL_EHLO_DOMAIN', '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)
), ),
], ],

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('location')->nullable()->after('avatar_path');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('location');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->unsignedInteger('views_count')->default(0)->after('body');
});
}
public function down(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->dropColumn('views_count');
});
}
};

View File

@@ -114,6 +114,24 @@ function PortalHeader({
return 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 }]) setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
} }

View File

@@ -74,6 +74,16 @@ export async function getCurrentUser() {
return apiFetch('/user/me') 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) { export async function uploadAvatar(file) {
const body = new FormData() const body = new FormData()
body.append('file', file) body.append('file', file)
@@ -91,6 +101,10 @@ export async function fetchVersion() {
return apiFetch('/version') return apiFetch('/version')
} }
export async function fetchStats() {
return apiFetch('/stats')
}
export async function fetchSetting(key) { export async function fetchSetting(key) {
// TODO: Prefer fetchSettings() when multiple settings are needed. // TODO: Prefer fetchSettings() when multiple settings are needed.
const cacheBust = Date.now() const cacheBust = Date.now()

View File

@@ -1181,6 +1181,33 @@ a {
font-size: 0.9rem; 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 { .bb-board-title {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -1425,6 +1452,16 @@ a {
color: var(--bb-ink-muted); 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 { .bb-portal-topic-forum-link {
color: var(--bb-accent, #f29b3f); color: var(--bb-accent, #f29b3f);
font-weight: 600; font-weight: 600;
@@ -1441,6 +1478,36 @@ a {
font-weight: 600; 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 { .bb-portal-user-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1061,7 +1061,7 @@ export default function Acp({ isAdmin }) {
<div className="fw-semibold d-flex align-items-center gap-2"> <div className="fw-semibold d-flex align-items-center gap-2">
<span>{node.name}</span> <span>{node.name}</span>
</div> </div>
<div className="bb-muted">{node.description || t('forum.no_description')}</div> <div className="bb-muted">{node.description || ''}</div>
</div> </div>
</div> </div>
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">

View File

@@ -113,7 +113,7 @@ export default function BoardIndex() {
<Link to={`/forum/${node.id}`} className="bb-board-link"> <Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name} {node.name}
</Link> </Link>
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div> <div className="bb-board-desc">{node.description || ''}</div>
{node.children?.length > 0 && ( {node.children?.length > 0 && (
<div className="bb-board-subforums"> <div className="bb-board-subforums">
{t('forum.children')}:{' '} {t('forum.children')}:{' '}
@@ -130,10 +130,28 @@ export default function BoardIndex() {
</div> </div>
</div> </div>
</div> </div>
<div className="bb-board-cell bb-board-cell--topics"></div> <div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts"></div> <div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last"> <div className="bb-board-cell bb-board-cell--last">
<span className="bb-muted">{t('thread.no_replies')}</span> {node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">
{node.last_post_at.slice(0, 10)}
</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div> </div>
</div> </div>
)) ))

View File

@@ -31,14 +31,30 @@ export default function ForumView() {
<Link to={`/forum/${node.id}`} className="bb-board-link"> <Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name} {node.name}
</Link> </Link>
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div> <div className="bb-board-desc">{node.description || ''}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="bb-board-cell bb-board-cell--topics"></div> <div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts"></div> <div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last"> <div className="bb-board-cell bb-board-cell--last">
<span className="bb-muted">{t('thread.no_replies')}</span> {node.last_post_at ? (
<div className="bb-board-last">
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div> </div>
</div> </div>
)) ))
@@ -190,18 +206,33 @@ export default function ForumView() {
</div> </div>
</div> </div>
</div> </div>
<div className="bb-topic-cell bb-topic-cell--replies">0</div> <div className="bb-topic-cell bb-topic-cell--replies">
<div className="bb-topic-cell bb-topic-cell--views"></div> {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-cell bb-topic-cell--last">
<div className="bb-topic-last"> <div className="bb-topic-last">
<span className="bb-topic-last-by"> <span className="bb-topic-last-by">
{t('thread.by')}{' '} {t('thread.by')}{' '}
<span className="bb-topic-author"> {thread.last_post_user_id ? (
{thread.user_name || t('thread.anonymous')} <Link
</span> 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> </span>
{thread.created_at && ( {thread.last_post_at && (
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span> <span className="bb-topic-date">
{thread.last_post_at.slice(0, 10)}
</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,16 +1,18 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Badge, Container } from 'react-bootstrap' import { Container } from 'react-bootstrap'
import { Link } from 'react-router-dom' 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 { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
export default function Home() { export default function Home() {
const [forums, setForums] = useState([]) const [forums, setForums] = useState([])
const [threads, setThreads] = useState([]) const [threads, setThreads] = useState([])
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
const [error, setError] = useState('') const [error, setError] = useState('')
const [loadingForums, setLoadingForums] = useState(true) const [loadingForums, setLoadingForums] = useState(true)
const [loadingThreads, setLoadingThreads] = useState(true) const [loadingThreads, setLoadingThreads] = useState(true)
const [loadingStats, setLoadingStats] = useState(true)
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
const { token, roles, email } = useAuth() const { token, roles, email } = useAuth()
const { t } = useTranslation() const { t } = useTranslation()
@@ -29,6 +31,21 @@ export default function Home() {
.finally(() => setLoadingThreads(false)) .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(() => { useEffect(() => {
if (!token) { if (!token) {
setProfile(null) setProfile(null)
@@ -110,6 +127,19 @@ export default function Home() {
return t('portal.user_role_member') return t('portal.user_role_member')
}, [roles, t]) }, [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) => { const resolveForumName = (thread) => {
if (!thread?.forum) return t('portal.unknown_forum') if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/') const parts = thread.forum.split('/')
@@ -138,7 +168,7 @@ export default function Home() {
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold"> <Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
{node.name} {node.name}
</Link> </Link>
<div className="bb-muted">{node.description || t('forum.no_description')}</div> <div className="bb-muted">{node.description || ''}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -165,11 +195,15 @@ export default function Home() {
<div className="bb-portal-card-title">{t('portal.stats')}</div> <div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat"> <div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span> <span>{t('portal.stat_threads')}</span>
<strong>{threads.length}</strong> <strong>{loadingStats ? '—' : stats.threads}</strong>
</div> </div>
<div className="bb-portal-stat"> <div className="bb-portal-stat">
<span>{t('portal.stat_forums')}</span> <span>{t('portal.stat_users')}</span>
<strong>{forums.length}</strong> <strong>{loadingStats ? '—' : stats.users}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_posts')}</span>
<strong>{loadingStats ? '—' : stats.posts}</strong>
</div> </div>
</div> </div>
</aside> </aside>
@@ -201,9 +235,18 @@ export default function Home() {
</Link> </Link>
<div className="bb-portal-topic-meta"> <div className="bb-portal-topic-meta">
<span>{t('thread.by')}</span> <span>{t('thread.by')}</span>
<Badge bg="secondary"> {thread.user_id ? (
{thread.user_name || t('thread.anonymous')} <Link
</Badge> 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"> <span className="bb-portal-topic-forum">
{t('portal.forum_label')}{' '} {t('portal.forum_label')}{' '}
{resolveForumId(thread) ? ( {resolveForumId(thread) ? (
@@ -220,10 +263,30 @@ export default function Home() {
</div> </div>
</div> </div>
</div> </div>
<div className="bb-portal-topic-cell">0</div> <div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
<div className="bb-portal-topic-cell"></div> <div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
<div className="bb-portal-topic-cell"> <div className="bb-portal-topic-cell">
{thread.created_at?.slice(0, 10) || '—'} <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>
</div> </div>
))} ))}

View File

@@ -64,6 +64,7 @@ export default function ThreadView() {
user_avatar_url: thread.user_avatar_url, user_avatar_url: thread.user_avatar_url,
user_posts_count: thread.user_posts_count, user_posts_count: thread.user_posts_count,
user_created_at: thread.user_created_at, user_created_at: thread.user_created_at,
user_location: thread.user_location,
user_rank_name: thread.user_rank_name, user_rank_name: thread.user_rank_name,
user_rank_badge_type: thread.user_rank_badge_type, user_rank_badge_type: thread.user_rank_badge_type,
user_rank_badge_text: thread.user_rank_badge_text, user_rank_badge_text: thread.user_rank_badge_text,
@@ -171,8 +172,10 @@ export default function ThreadView() {
</span> </span>
</div> </div>
<div className="bb-post-author-stat"> <div className="bb-post-author-stat">
<span className="bb-post-author-label">Location:</span> <span className="bb-post-author-label">{t('thread.location')}:</span>
<span className="bb-post-author-value">Kollmar</span> <span className="bb-post-author-value">
{post.user_location || '-'}
</span>
</div> </div>
<div className="bb-post-author-stat"> <div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks given:</span> <span className="bb-post-author-label">Thanks given:</span>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Container, Form, Row, Col } from 'react-bootstrap' import { Container, Form, Row, Col, Button } from 'react-bootstrap'
import { getCurrentUser, uploadAvatar } from '../api/client' import { getCurrentUser, updateCurrentUser, uploadAvatar } from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -11,6 +11,10 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
const [avatarError, setAvatarError] = useState('') const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false) const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('') const [avatarPreview, setAvatarPreview] = useState('')
const [location, setLocation] = useState('')
const [profileError, setProfileError] = useState('')
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
useEffect(() => { useEffect(() => {
if (!token) return if (!token) return
@@ -20,6 +24,7 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
.then((data) => { .then((data) => {
if (!active) return if (!active) return
setAvatarPreview(data?.avatar_url || '') setAvatarPreview(data?.avatar_url || '')
setLocation(data?.location || '')
}) })
.catch(() => { .catch(() => {
if (active) setAvatarPreview('') if (active) setAvatarPreview('')
@@ -76,6 +81,43 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
/> />
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text> <Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('ucp.location_label')}</Form.Label>
<Form.Control
type="text"
value={location}
disabled={!token || profileSaving}
onChange={(event) => {
setLocation(event.target.value)
setProfileSaved(false)
}}
/>
<Form.Text className="bb-muted">{t('ucp.location_hint')}</Form.Text>
</Form.Group>
{profileError && <p className="text-danger mt-2 mb-0">{profileError}</p>}
{profileSaved && <p className="text-success mt-2 mb-0">{t('ucp.profile_saved')}</p>}
<Button
type="button"
variant="outline-light"
className="mt-3"
disabled={!token || profileSaving}
onClick={async () => {
setProfileError('')
setProfileSaved(false)
setProfileSaving(true)
try {
const response = await updateCurrentUser({ location })
setLocation(response?.location || '')
setProfileSaved(true)
} catch (err) {
setProfileError(err.message)
} finally {
setProfileSaving(false)
}
}}
>
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
</Button>
</Col> </Col>
</Row> </Row>
</div> </div>

View File

@@ -90,8 +90,7 @@
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.", "forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
"forum.loading": "Forum wird geladen...", "forum.loading": "Forum wird geladen...",
"forum.login_hint": "Melde dich an, um einen neuen Thread zu erstellen.", "forum.login_hint": "Melde dich an, um einen neuen Thread zu erstellen.",
"forum.no_description": "Noch keine Beschreibung vorhanden.", "forum.only_forums": "Threads können nur in Foren erstellt werden.",
"forum.only_forums": "Threads können nur in Foren erstellt werden.",
"forum.open": "Forum öffnen", "forum.open": "Forum öffnen",
"forum.collapse_category": "Kategorie einklappen", "forum.collapse_category": "Kategorie einklappen",
"forum.expand_category": "Kategorie ausklappen", "forum.expand_category": "Kategorie ausklappen",
@@ -155,7 +154,8 @@
"portal.menu_rules": "Forenregeln", "portal.menu_rules": "Forenregeln",
"portal.stats": "Statistik", "portal.stats": "Statistik",
"portal.stat_threads": "Themen", "portal.stat_threads": "Themen",
"portal.stat_forums": "Foren", "portal.stat_users": "Benutzer",
"portal.stat_posts": "Beiträge",
"portal.latest_posts": "Aktuelle Beiträge", "portal.latest_posts": "Aktuelle Beiträge",
"portal.empty_posts": "Noch keine Beiträge.", "portal.empty_posts": "Noch keine Beiträge.",
"portal.topic": "Themen", "portal.topic": "Themen",
@@ -179,6 +179,10 @@
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.", "ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
"ucp.avatar_label": "Profilbild", "ucp.avatar_label": "Profilbild",
"ucp.avatar_hint": "Lade ein Bild hoch (max. 150x150px, Du kannst jpg, png, gif oder webp verwenden).", "ucp.avatar_hint": "Lade ein Bild hoch (max. 150x150px, Du kannst jpg, png, gif oder webp verwenden).",
"ucp.location_label": "Wohnort",
"ucp.location_hint": "Wird neben Deinen Beiträgen und im Profil angezeigt.",
"ucp.save_profile": "Profil speichern",
"ucp.profile_saved": "Profil gespeichert.",
"ucp.system_default": "Systemstandard", "ucp.system_default": "Systemstandard",
"ucp.accent_override": "Akzentfarbe überschreiben", "ucp.accent_override": "Akzentfarbe überschreiben",
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.", "ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
@@ -191,6 +195,7 @@
"thread.loading": "Thread wird geladen...", "thread.loading": "Thread wird geladen...",
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.", "thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
"thread.posts": "Beiträge", "thread.posts": "Beiträge",
"thread.location": "Wohnort",
"thread.reply_prefix": "Aw:", "thread.reply_prefix": "Aw:",
"thread.registered": "Registriert", "thread.registered": "Registriert",
"thread.replies": "Antworten", "thread.replies": "Antworten",

View File

@@ -90,7 +90,6 @@
"forum.empty_threads": "No threads here yet. Start one below.", "forum.empty_threads": "No threads here yet. Start one below.",
"forum.loading": "Loading forum...", "forum.loading": "Loading forum...",
"forum.login_hint": "Log in to create a new thread.", "forum.login_hint": "Log in to create a new thread.",
"forum.no_description": "No description added yet.",
"forum.only_forums": "Threads can only be created in forums.", "forum.only_forums": "Threads can only be created in forums.",
"forum.open": "Open forum", "forum.open": "Open forum",
"forum.collapse_category": "Collapse category", "forum.collapse_category": "Collapse category",
@@ -155,7 +154,8 @@
"portal.menu_rules": "Forum rules", "portal.menu_rules": "Forum rules",
"portal.stats": "Statistics", "portal.stats": "Statistics",
"portal.stat_threads": "Threads", "portal.stat_threads": "Threads",
"portal.stat_forums": "Forums", "portal.stat_users": "Users",
"portal.stat_posts": "Posts",
"portal.latest_posts": "Latest posts", "portal.latest_posts": "Latest posts",
"portal.empty_posts": "No posts yet.", "portal.empty_posts": "No posts yet.",
"portal.topic": "Topics", "portal.topic": "Topics",
@@ -179,6 +179,10 @@
"ucp.profile_hint": "Update the avatar shown next to your posts.", "ucp.profile_hint": "Update the avatar shown next to your posts.",
"ucp.avatar_label": "Profile image", "ucp.avatar_label": "Profile image",
"ucp.avatar_hint": "Upload an image (max 150x150px, you can use jpg, png, gif, or webp).", "ucp.avatar_hint": "Upload an image (max 150x150px, you can use jpg, png, gif, or webp).",
"ucp.location_label": "Location",
"ucp.location_hint": "Shown next to your posts and profile.",
"ucp.save_profile": "Save profile",
"ucp.profile_saved": "Profile saved.",
"ucp.system_default": "System default", "ucp.system_default": "System default",
"ucp.accent_override": "Accent color override", "ucp.accent_override": "Accent color override",
"ucp.accent_override_hint": "Choose a custom accent color for your UI.", "ucp.accent_override_hint": "Choose a custom accent color for your UI.",
@@ -191,6 +195,7 @@
"thread.loading": "Loading thread...", "thread.loading": "Loading thread...",
"thread.login_hint": "Log in to reply to this thread.", "thread.login_hint": "Log in to reply to this thread.",
"thread.posts": "Posts", "thread.posts": "Posts",
"thread.location": "Location",
"thread.reply_prefix": "Re:", "thread.reply_prefix": "Re:",
"thread.registered": "Registered", "thread.registered": "Registered",
"thread.replies": "Replies", "thread.replies": "Replies",

View File

@@ -5,6 +5,7 @@ use App\Http\Controllers\ForumController;
use App\Http\Controllers\I18nController; use App\Http\Controllers\I18nController;
use App\Http\Controllers\PostController; use App\Http\Controllers\PostController;
use App\Http\Controllers\SettingController; use App\Http\Controllers\SettingController;
use App\Http\Controllers\StatsController;
use App\Http\Controllers\ThreadController; use App\Http\Controllers\ThreadController;
use App\Http\Controllers\UploadController; use App\Http\Controllers\UploadController;
use App\Http\Controllers\UserSettingController; use App\Http\Controllers\UserSettingController;
@@ -24,6 +25,7 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum'); Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
Route::get('/version', VersionController::class); Route::get('/version', VersionController::class);
Route::get('/stats', StatsController::class);
Route::get('/settings', [SettingController::class, 'index']); Route::get('/settings', [SettingController::class, 'index']);
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum'); Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum'); Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
@@ -36,6 +38,7 @@ Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum'); Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum'); Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum'); Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum'); Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum'); Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum'); Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB