Compare commits
2 Commits
fd29b928d8
...
fix-replie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c16ed0dd | ||
|
|
f9de433545 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
- Added ACP users search and improved sorting indicators.
|
- Added ACP users search and improved sorting indicators.
|
||||||
- Added thread sidebar fields for posts count, registration date, and topic header.
|
- Added thread sidebar fields for posts count, registration date, and topic header.
|
||||||
- Linked header logo to the portal and fixed ACP breadcrumbs.
|
- 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
|
## 2026-01-11
|
||||||
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
app/Http/Controllers/PortalController.php
Normal file
149
app/Http/Controllers/PortalController.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Forum;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Thread;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PortalController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$forums = Forum::query()
|
||||||
|
->withoutTrashed()
|
||||||
|
->withCount(['threads', 'posts'])
|
||||||
|
->withSum('threads', 'views_count')
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$forumIds = $forums->pluck('id')->all();
|
||||||
|
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
|
||||||
|
|
||||||
|
$forumPayload = $forums->map(
|
||||||
|
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
|
||||||
|
);
|
||||||
|
|
||||||
|
$threads = Thread::query()
|
||||||
|
->withoutTrashed()
|
||||||
|
->withCount('posts')
|
||||||
|
->with([
|
||||||
|
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||||
|
'latestPost.user',
|
||||||
|
])
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(12)
|
||||||
|
->get()
|
||||||
|
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||||
|
'posts' => Post::query()->withoutTrashed()->count(),
|
||||||
|
'users' => User::query()->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$user = auth('sanctum')->user();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'forums' => $forumPayload,
|
||||||
|
'threads' => $threads,
|
||||||
|
'stats' => $stats,
|
||||||
|
'profile' => $user ? [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'avatar_url' => $user->avatar_path ? Storage::url($user->avatar_path) : null,
|
||||||
|
'location' => $user->location,
|
||||||
|
'rank' => $user->rank ? [
|
||||||
|
'id' => $user->rank->id,
|
||||||
|
'name' => $user->rank->name,
|
||||||
|
] : null,
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeForum(Forum $forum, ?Post $lastPost): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $forum->id,
|
||||||
|
'name' => $forum->name,
|
||||||
|
'description' => $forum->description,
|
||||||
|
'type' => $forum->type,
|
||||||
|
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||||
|
'position' => $forum->position,
|
||||||
|
'threads_count' => $forum->threads_count ?? 0,
|
||||||
|
'posts_count' => $forum->posts_count ?? 0,
|
||||||
|
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||||
|
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||||
|
'last_post_user_id' => $lastPost?->user_id,
|
||||||
|
'last_post_user_name' => $lastPost?->user?->name,
|
||||||
|
'created_at' => $forum->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeThread(Thread $thread): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $thread->id,
|
||||||
|
'title' => $thread->title,
|
||||||
|
'body' => $thread->body,
|
||||||
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
|
'user_id' => $thread->user_id,
|
||||||
|
'posts_count' => $thread->posts_count ?? 0,
|
||||||
|
'views_count' => $thread->views_count ?? 0,
|
||||||
|
'user_name' => $thread->user?->name,
|
||||||
|
'user_avatar_url' => $thread->user?->avatar_path
|
||||||
|
? Storage::url($thread->user->avatar_path)
|
||||||
|
: null,
|
||||||
|
'user_posts_count' => $thread->user?->posts_count,
|
||||||
|
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||||
|
'user_rank_name' => $thread->user?->rank?->name,
|
||||||
|
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||||
|
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
|
||||||
|
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||||
|
? Storage::url($thread->user->rank->badge_image_path)
|
||||||
|
: null,
|
||||||
|
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||||
|
?? $thread->created_at?->toIso8601String(),
|
||||||
|
'last_post_id' => $thread->latestPost?->id,
|
||||||
|
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||||
|
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||||
|
?? $thread->user?->name,
|
||||||
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadLastPostsByForum(array $forumIds): array
|
||||||
|
{
|
||||||
|
if (empty($forumIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = Post::query()
|
||||||
|
->select('posts.*', 'threads.forum_id as forum_id')
|
||||||
|
->join('threads', 'posts.thread_id', '=', 'threads.id')
|
||||||
|
->whereIn('threads.forum_id', $forumIds)
|
||||||
|
->whereNull('posts.deleted_at')
|
||||||
|
->whereNull('threads.deleted_at')
|
||||||
|
->orderByDesc('posts.created_at')
|
||||||
|
->with('user')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byForum = [];
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$forumId = (int) ($post->forum_id ?? 0);
|
||||||
|
if ($forumId && !array_key_exists($forumId, $byForum)) {
|
||||||
|
$byForum[$forumId] = $post;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byForum;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
20
app/Http/Controllers/StatsController.php
Normal file
20
app/Http/Controllers/StatsController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,27 @@ 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_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(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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": "*",
|
||||||
|
|||||||
@@ -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)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 }])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 @@ export async function fetchVersion() {
|
|||||||
return apiFetch('/version')
|
return apiFetch('/version')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStats() {
|
||||||
|
return apiFetch('/stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortalSummary() {
|
||||||
|
return apiFetch('/portal/summary')
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
91
resources/js/components/PortalTopicRow.jsx
Normal file
91
resources/js/components/PortalTopicRow.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const authorName = thread.user_name || t('thread.anonymous')
|
||||||
|
const lastAuthorName = thread.last_post_user_name || authorName
|
||||||
|
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = String(date.getFullYear())
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bb-portal-topic-row">
|
||||||
|
<div className="bb-portal-topic-main">
|
||||||
|
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||||
|
<i className="bi bi-chat-left-text" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||||
|
{thread.title}
|
||||||
|
</Link>
|
||||||
|
<div className="bb-portal-topic-meta">
|
||||||
|
<div className="bb-portal-topic-meta-line">
|
||||||
|
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
||||||
|
{thread.user_id ? (
|
||||||
|
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
|
||||||
|
{authorName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="bb-portal-topic-author">{authorName}</span>
|
||||||
|
)}
|
||||||
|
<span className="bb-portal-topic-meta-sep">»</span>
|
||||||
|
<span className="bb-portal-topic-meta-date">{formatDateTime(thread.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{showForum && (
|
||||||
|
<div className="bb-portal-topic-meta-line">
|
||||||
|
<span className="bb-portal-topic-meta-label">{t('portal.forum_label')}</span>
|
||||||
|
<span className="bb-portal-topic-forum">
|
||||||
|
{forumId ? (
|
||||||
|
<Link to={`/forum/${forumId}`} className="bb-portal-topic-forum-link">
|
||||||
|
{forumName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
forumName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
|
||||||
|
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
||||||
|
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
|
||||||
|
<div className="bb-portal-last">
|
||||||
|
<span className="bb-portal-last-by">
|
||||||
|
{t('thread.by')}{' '}
|
||||||
|
{thread.last_post_user_id ? (
|
||||||
|
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
|
||||||
|
{lastAuthorName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="bb-portal-last-user">{lastAuthorName}</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={`/thread/${thread.id}${lastPostAnchor}`}
|
||||||
|
className="bb-portal-last-jump ms-2"
|
||||||
|
aria-label={t('thread.view')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-eye" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<span className="bb-portal-last-date">
|
||||||
|
{formatDateTime(thread.last_post_at || thread.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -1412,19 +1439,50 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-topic-meta {
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
font-size: 0.8rem;
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-portal-topic-meta-label {
|
||||||
color: var(--bb-ink-muted);
|
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 {
|
.bb-portal-topic-forum {
|
||||||
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 +1499,49 @@ a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-portal-topic-cell--last {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-user {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.bb-portal-user-card {
|
.bb-portal-user-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1470,13 +1571,22 @@ a {
|
|||||||
max-height: 150px;
|
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 {
|
.bb-portal-user-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-user-role {
|
.bb-portal-user-role {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-ink-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||||
|
import PortalTopicRow from '../components/PortalTopicRow'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -31,14 +32,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>
|
||||||
))
|
))
|
||||||
@@ -156,56 +173,24 @@ export default function ForumView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<div className="bb-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-topic-header">
|
<div className="bb-portal-topic-header">
|
||||||
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
|
<span>{t('portal.topic')}</span>
|
||||||
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
|
<span>{t('thread.replies')}</span>
|
||||||
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
|
<span>{t('thread.views')}</span>
|
||||||
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
|
<span>{t('thread.last_post')}</span>
|
||||||
</div>
|
</div>
|
||||||
{threads.length === 0 && (
|
{threads.length === 0 && (
|
||||||
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
||||||
)}
|
)}
|
||||||
{threads.map((thread) => (
|
{threads.map((thread) => (
|
||||||
<div className="bb-topic-row" key={thread.id}>
|
<PortalTopicRow
|
||||||
<div className="bb-topic-cell bb-topic-cell--title">
|
key={thread.id}
|
||||||
<div className="bb-topic-title">
|
thread={thread}
|
||||||
<span className="bb-topic-icon" aria-hidden="true">
|
forumName={forum?.name || t('portal.unknown_forum')}
|
||||||
<i className="bi bi-chat-left" />
|
forumId={forum?.id}
|
||||||
</span>
|
showForum={false}
|
||||||
<div className="bb-topic-text">
|
/>
|
||||||
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
|
|
||||||
<div className="bb-topic-meta">
|
|
||||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
|
||||||
<span>{t('thread.by')}</span>
|
|
||||||
<span className="bb-topic-author">
|
|
||||||
{thread.user_name || t('thread.anonymous')}
|
|
||||||
</span>
|
|
||||||
{thread.created_at && (
|
|
||||||
<span className="bb-topic-date">
|
|
||||||
{thread.created_at.slice(0, 10)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
|
|
||||||
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
|
||||||
<div className="bb-topic-cell bb-topic-cell--last">
|
|
||||||
<div className="bb-topic-last">
|
|
||||||
<span className="bb-topic-last-by">
|
|
||||||
{t('thread.by')}{' '}
|
|
||||||
<span className="bb-topic-author">
|
|
||||||
{thread.user_name || t('thread.anonymous')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{thread.created_at && (
|
|
||||||
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,48 +1,55 @@
|
|||||||
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 { fetchPortalSummary } from '../api/client'
|
||||||
|
import PortalTopicRow from '../components/PortalTopicRow'
|
||||||
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()
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
|
||||||
if (!token) {
|
|
||||||
setProfile(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let active = true
|
let active = true
|
||||||
|
setLoadingForums(true)
|
||||||
|
setLoadingThreads(true)
|
||||||
|
setLoadingStats(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
getCurrentUser()
|
fetchPortalSummary()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!active) return
|
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(() => {
|
.catch((err) => {
|
||||||
if (active) setProfile(null)
|
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 () => {
|
return () => {
|
||||||
@@ -138,7 +145,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 +172,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>
|
||||||
@@ -190,42 +201,12 @@ export default function Home() {
|
|||||||
<span>{t('thread.last_post')}</span>
|
<span>{t('thread.last_post')}</span>
|
||||||
</div>
|
</div>
|
||||||
{recentThreads.map((thread) => (
|
{recentThreads.map((thread) => (
|
||||||
<div className="bb-portal-topic-row" key={thread.id}>
|
<PortalTopicRow
|
||||||
<div className="bb-portal-topic-main">
|
key={thread.id}
|
||||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
thread={thread}
|
||||||
<i className="bi bi-chat-left-text" />
|
forumName={resolveForumName(thread)}
|
||||||
</span>
|
forumId={resolveForumId(thread)}
|
||||||
<div>
|
/>
|
||||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
|
||||||
{thread.title}
|
|
||||||
</Link>
|
|
||||||
<div className="bb-portal-topic-meta">
|
|
||||||
<span>{t('thread.by')}</span>
|
|
||||||
<Badge bg="secondary">
|
|
||||||
{thread.user_name || t('thread.anonymous')}
|
|
||||||
</Badge>
|
|
||||||
<span className="bb-portal-topic-forum">
|
|
||||||
{t('portal.forum_label')}{' '}
|
|
||||||
{resolveForumId(thread) ? (
|
|
||||||
<Link
|
|
||||||
to={`/forum/${resolveForumId(thread)}`}
|
|
||||||
className="bb-portal-topic-forum-link"
|
|
||||||
>
|
|
||||||
{resolveForumName(thread)}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
resolveForumName(thread)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bb-portal-topic-cell">0</div>
|
|
||||||
<div className="bb-portal-topic-cell">—</div>
|
|
||||||
<div className="bb-portal-topic-cell">
|
|
||||||
{thread.created_at?.slice(0, 10) || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -236,14 +217,22 @@ export default function Home() {
|
|||||||
<div className="bb-portal-card">
|
<div className="bb-portal-card">
|
||||||
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
||||||
<div className="bb-portal-user-card">
|
<div className="bb-portal-user-card">
|
||||||
<div className="bb-portal-user-avatar">
|
<Link to="/ucp" className="bb-portal-user-avatar">
|
||||||
{profile?.avatar_url ? (
|
{profile?.avatar_url ? (
|
||||||
<img src={profile.avatar_url} alt="" />
|
<img src={profile.avatar_url} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<i className="bi bi-person" aria-hidden="true" />
|
<i className="bi bi-person" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
</Link>
|
||||||
|
<div className="bb-portal-user-name">
|
||||||
|
{profile?.id ? (
|
||||||
|
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
|
||||||
|
{profile?.name || email || 'User'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
profile?.name || email || 'User'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-portal-user-name">{profile?.name || email || 'User'}</div>
|
|
||||||
<div className="bb-portal-user-role">{roleLabel}</div>
|
<div className="bb-portal-user-role">{roleLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="bb-portal-list">
|
<ul className="bb-portal-list">
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ export default function ThreadView() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [id])
|
}, [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) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -64,6 +76,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,
|
||||||
@@ -135,7 +148,7 @@ export default function ThreadView() {
|
|||||||
const postNumber = index + 1
|
const postNumber = index + 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bb-post-row" key={post.id}>
|
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
|
||||||
<aside className="bb-post-author">
|
<aside className="bb-post-author">
|
||||||
<div className="bb-post-avatar">
|
<div className="bb-post-avatar">
|
||||||
{post.user_avatar_url ? (
|
{post.user_avatar_url ? (
|
||||||
@@ -171,8 +184,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>
|
||||||
@@ -219,6 +234,9 @@ export default function ThreadView() {
|
|||||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||||
<i className="bi bi-quote" aria-hidden="true" />
|
<i className="bi bi-quote" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
|
||||||
|
<i className="bi bi-house-door" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-body">{post.body}</div>
|
<div className="bb-post-body">{post.body}</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,10 +154,12 @@
|
|||||||
"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",
|
||||||
|
"portal.posted_by": "Verfasst von",
|
||||||
"portal.forum_label": "Forum:",
|
"portal.forum_label": "Forum:",
|
||||||
"portal.unknown_forum": "Unbekannt",
|
"portal.unknown_forum": "Unbekannt",
|
||||||
"portal.user_menu": "Benutzer-Menü",
|
"portal.user_menu": "Benutzer-Menü",
|
||||||
@@ -179,6 +180,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 +196,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",
|
||||||
|
|||||||
@@ -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,10 +154,12 @@
|
|||||||
"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",
|
||||||
|
"portal.posted_by": "Posted by",
|
||||||
"portal.forum_label": "Forum:",
|
"portal.forum_label": "Forum:",
|
||||||
"portal.unknown_forum": "Unknown",
|
"portal.unknown_forum": "Unknown",
|
||||||
"portal.user_menu": "User menu",
|
"portal.user_menu": "User menu",
|
||||||
@@ -179,6 +180,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 +196,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",
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\ForumController;
|
use App\Http\Controllers\ForumController;
|
||||||
use App\Http\Controllers\I18nController;
|
use App\Http\Controllers\I18nController;
|
||||||
|
use App\Http\Controllers\PortalController;
|
||||||
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 +26,8 @@ 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('/portal/summary', PortalController::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 +40,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 |
Reference in New Issue
Block a user