7 Commits

Author SHA1 Message Date
Micha
24c16ed0dd Unify portal thread rows and add summary API 2026-01-16 02:44:04 +01:00
Micha
f9de433545 fixing thsu frontend views 2026-01-16 01:43:07 +01:00
Micha
fd29b928d8 Add ranks and ACP user enhancements 2026-01-14 00:15:56 +01:00
Micha
98094459e3 Tighten ACP forum actions and avatar handling 2026-01-13 00:07:25 +01:00
Micha
3bb2946656 Add avatars, profiles, and auth flows 2026-01-12 23:40:11 +01:00
Micha
bbbf8eb6c1 Show post authors and action buttons 2026-01-11 01:54:45 +01:00
Micha
c8d2bd508e Restyle thread view like phpBB 2026-01-11 01:25:16 +01:00
51 changed files with 3243 additions and 279 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.log *.log
.DS_Store .DS_Store
._*
.env .env
.env.backup .env.backup
.env.production .env.production
@@ -22,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

@@ -1,5 +1,26 @@
# Changelog # Changelog
## 2026-01-12
- Switched main SPA layouts to fluid containers to reduce wasted space.
- Added username-or-email login with case-insensitive unique usernames.
- Added SPA-friendly verification and password reset/update endpoints.
- Added user avatars (upload + display) and a basic profile page/API.
- Seeded a Micha test user with verified email.
- Added rank management with badge text/image options and ACP UI controls.
- Added user edit modal (name/email/rank) and rank assignment controls in ACP.
- Added ACP users search and improved sorting indicators.
- Added thread sidebar fields for posts count, registration date, and topic header.
- Linked header logo to the portal and fixed ACP breadcrumbs.
- Added profile location field with UCP editing and post sidebar display.
- Added per-thread replies and views counts, including view tracking.
- Added per-forum topics/views counts plus last-post details in board listings.
- Added portal summary API to load forums, stats, and recent posts in one request.
- Unified portal and forum thread list row styling with shared component.
## 2026-01-11
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
- Added phpBB-style post action buttons and post author info for replies.
## 2026-01-02 ## 2026-01-02
- Added ACP general settings for forum name, theme, accents, and logo (no reload required). - Added ACP general settings for forum name, theme, accents, and logo (no reload required).
- Added admin-only upload endpoints and ACP UI for logos and favicons. - Added admin-only upload endpoints and ACP UI for logos and favicons.

View File

@@ -3,6 +3,7 @@
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -19,8 +20,16 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User public function create(array $input): User
{ {
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
Validator::make(data: $input, rules: [ Validator::make(data: $input, rules: [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'name_canonical' => [
'required',
'string',
'max:255',
Rule::unique(table: User::class, column: 'name_canonical'),
],
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
@@ -33,6 +42,7 @@ class CreateNewUser implements CreatesNewUsers
return User::create(attributes: [ return User::create(attributes: [
'name' => $input['name'], 'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'], 'email' => $input['email'],
'password' => Hash::make(value: $input['password']), 'password' => Hash::make(value: $input['password']),
]); ]);

View File

@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation; use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/ */
public function update(User $user, array $input): void public function update(User $user, array $input): void
{ {
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
Validator::make($input, [ Validator::make($input, [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'name_canonical' => [
'required',
'string',
'max:255',
Rule::unique('users', 'name_canonical')->ignore($user->id),
],
'email' => [ 'email' => [
'required', 'required',
@@ -34,6 +43,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
} else { } else {
$user->forceFill([ $user->forceFill([
'name' => $input['name'], 'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'], 'email' => $input['email'],
])->save(); ])->save();
} }
@@ -48,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{ {
$user->forceFill([ $user->forceFill([
'name' => $input['name'], 'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'], 'email' => $input['email'],
'email_verified_at' => null, 'email_verified_at' => null,
])->save(); ])->save();

View File

@@ -3,14 +3,22 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\PasswordValidationRules;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class AuthController extends Controller class AuthController extends Controller
{ {
use PasswordValidationRules;
public function register(Request $request, CreateNewUser $creator): JsonResponse public function register(Request $request, CreateNewUser $creator): JsonResponse
{ {
$input = [ $input = [
@@ -33,16 +41,30 @@ class AuthController extends Controller
public function login(Request $request): JsonResponse public function login(Request $request): JsonResponse
{ {
$request->merge([
'login' => $request->input('login', $request->input('email')),
]);
$request->validate([ $request->validate([
'email' => ['required', 'email'], 'login' => ['required', 'string'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
]); ]);
$user = User::where('email', $request->input('email'))->first(); $login = trim((string) $request->input('login'));
$loginNormalized = Str::lower($login);
$userQuery = User::query();
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
$userQuery->whereRaw('lower(email) = ?', [$loginNormalized]);
} else {
$userQuery->where('name_canonical', $loginNormalized);
}
$user = $userQuery->first();
if (!$user || !Hash::check($request->input('password'), $user->password)) { if (!$user || !Hash::check($request->input('password'), $user->password)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => ['Invalid credentials.'], 'login' => ['Invalid credentials.'],
]); ]);
} }
@@ -62,6 +84,93 @@ class AuthController extends Controller
]); ]);
} }
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
{
$user = User::findOrFail($id);
if (!hash_equals($hash, sha1($user->getEmailForVerification()))) {
abort(403);
}
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}
return redirect('/login');
}
public function forgotPassword(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
$status = Password::sendResetLink(
$request->only('email')
);
if ($status !== Password::RESET_LINK_SENT) {
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
return response()->json(['message' => __($status)]);
}
public function resetPassword(Request $request): JsonResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => $this->passwordRules(),
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
if ($status !== Password::PASSWORD_RESET) {
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
return response()->json(['message' => __($status)]);
}
public function updatePassword(Request $request): JsonResponse
{
$request->validate([
'current_password' => ['required'],
'password' => $this->passwordRules(),
]);
$user = $request->user();
if (!$user || !Hash::check($request->input('current_password'), $user->password)) {
throw ValidationException::withMessages([
'current_password' => ['Invalid current password.'],
]);
}
$user->forceFill([
'password' => Hash::make($request->input('password')),
'remember_token' => Str::random(60),
])->save();
return response()->json(['message' => 'Password updated.']);
}
public function logout(Request $request): JsonResponse public function logout(Request $request): JsonResponse
{ {
$request->user()?->currentAccessToken()?->delete(); $request->user()?->currentAccessToken()?->delete();

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
@@ -68,7 +81,12 @@ class ForumController extends Controller
} }
} }
if ($parentId === null) {
Forum::whereNull('parent_id')->increment('position');
$position = 0;
} else {
$position = Forum::where('parent_id', $parentId)->max('position'); $position = Forum::where('parent_id', $parentId)->max('position');
}
$forum = Forum::create([ $forum = Forum::create([
'name' => $data['name'], 'name' => $data['name'],
@@ -78,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
@@ -122,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
@@ -175,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,
@@ -184,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

@@ -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;
}
}

View File

@@ -6,12 +6,15 @@ use App\Models\Post;
use App\Models\Thread; use App\Models\Thread;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller class PostController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Post::query()->withoutTrashed(); $query = Post::query()->withoutTrashed()->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]);
$threadParam = $request->query('thread'); $threadParam = $request->query('thread');
if (is_string($threadParam)) { if (is_string($threadParam)) {
@@ -45,6 +48,10 @@ class PostController extends Controller
'body' => $data['body'], 'body' => $data['body'],
]); ]);
$post->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]);
return response()->json($this->serializePost($post), 201); return response()->json($this->serializePost($post), 201);
} }
@@ -81,6 +88,19 @@ class PostController extends Controller
'body' => $post->body, 'body' => $post->body,
'thread' => "/api/threads/{$post->thread_id}", 'thread' => "/api/threads/{$post->thread_id}",
'user_id' => $post->user_id, 'user_id' => $post->user_id,
'user_name' => $post->user?->name,
'user_avatar_url' => $post->user?->avatar_path
? Storage::url($post->user->avatar_path)
: null,
'user_posts_count' => $post->user?->posts_count,
'user_created_at' => $post->user?->created_at?->toIso8601String(),
'user_location' => $post->user?->location,
'user_rank_name' => $post->user?->rank?->name,
'user_rank_badge_type' => $post->user?->rank?->badge_type,
'user_rank_badge_text' => $post->user?->rank?->badge_text,
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
? Storage::url($post->user->rank->badge_image_path)
: null,
'created_at' => $post->created_at?->toIso8601String(), 'created_at' => $post->created_at?->toIso8601String(),
'updated_at' => $post->updated_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(),
]; ];

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers;
use App\Models\Rank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class RankController extends Controller
{
private function ensureAdmin(Request $request): ?JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
return null;
}
public function index(Request $request): JsonResponse
{
$ranks = Rank::query()
->orderBy('name')
->get()
->map(fn (Rank $rank) => [
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path)
: null,
]);
return response()->json($ranks);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
'badge_type' => ['nullable', 'in:text,image'],
'badge_text' => ['nullable', 'string', 'max:40'],
]);
$badgeType = $data['badge_type'] ?? 'text';
$badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $data['name'])
: null;
$rank = Rank::create([
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
]);
return response()->json([
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'badge_image_url' => null,
], 201);
}
public function update(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
'badge_type' => ['nullable', 'in:text,image'],
'badge_text' => ['nullable', 'string', 'max:40'],
]);
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
$badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
: null;
if ($badgeType === 'text' && $rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
$rank->badge_image_path = null;
}
$rank->update([
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
]);
return response()->json([
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path)
: null,
]);
}
public function destroy(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if ($rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
}
$rank->delete();
return response()->json(null, 204);
}
public function uploadBadgeImage(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'],
]);
if ($rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
}
$path = $data['file']->store('rank-badges', 'public');
$rank->badge_type = 'image';
$rank->badge_text = null;
$rank->badge_image_path = $path;
$rank->save();
return response()->json([
'id' => $rank->id,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'badge_image_url' => Storage::url($path),
]);
}
}

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

@@ -6,12 +6,19 @@ use App\Models\Forum;
use App\Models\Thread; use App\Models\Thread;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ThreadController extends Controller class ThreadController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Thread::query()->withoutTrashed()->with('user'); $query = Thread::query()
->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)) {
@@ -31,7 +38,12 @@ class ThreadController extends Controller
public function show(Thread $thread): JsonResponse public function show(Thread $thread): JsonResponse
{ {
$thread->loadMissing('user'); $thread->increment('views_count');
$thread->refresh();
$thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
])->loadCount('posts');
return response()->json($this->serializeThread($thread)); return response()->json($this->serializeThread($thread));
} }
@@ -57,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);
} }
@@ -94,7 +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
? Storage::url($thread->user->avatar_path)
: null,
'user_posts_count' => $thread->user?->posts_count,
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_location' => $thread->user?->location,
'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_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(),
]; ];

View File

@@ -8,6 +8,37 @@ use Illuminate\Support\Facades\Storage;
class UploadController extends Controller class UploadController extends Controller
{ {
public function storeAvatar(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = $request->validate([
'file' => [
'required',
'image',
'mimes:jpg,jpeg,png,gif,webp',
'max:2048',
'dimensions:max_width=150,max_height=150',
],
]);
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$path = $data['file']->store('avatars', 'public');
$user->avatar_path = $path;
$user->save();
return response()->json([
'path' => $path,
'url' => Storage::url($path),
]);
}
public function storeLogo(Request $request): JsonResponse public function storeLogo(Request $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();

View File

@@ -4,22 +4,194 @@ namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserController extends Controller class UserController extends Controller
{ {
public function index(): JsonResponse public function index(): JsonResponse
{ {
$users = User::query() $users = User::query()
->with('roles') ->with(['roles', 'rank'])
->orderBy('id') ->orderBy('id')
->get() ->get()
->map(fn (User $user) => [ ->map(fn (User $user) => [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, '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(), 'roles' => $user->roles->pluck('name')->values(),
]); ]);
return response()->json($users); return response()->json($users);
} }
public function me(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
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 profile(User $user): JsonResponse
{
return response()->json([
'id' => $user->id,
'name' => $user->name,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'created_at' => $user->created_at?->toIso8601String(),
]);
}
public function updateMe(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
$data = $request->validate([
'location' => ['nullable', 'string', 'max:255'],
]);
$location = isset($data['location']) ? trim($data['location']) : null;
if ($location === '') {
$location = null;
}
$user->forceFill([
'location' => $location,
])->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'roles' => $user->roles()->pluck('name')->values(),
]);
}
public function updateRank(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'rank_id' => ['nullable', 'exists:ranks,id'],
]);
$user->rank_id = $data['rank_id'] ?? null;
$user->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
]);
}
public function update(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($user->id),
],
'rank_id' => ['nullable', 'exists:ranks,id'],
]);
$nameCanonical = Str::lower(trim($data['name']));
$nameConflict = User::query()
->where('id', '!=', $user->id)
->where('name_canonical', $nameCanonical)
->exists();
if ($nameConflict) {
return response()->json(['message' => 'Name already exists.'], 422);
}
if ($data['email'] !== $user->email) {
$user->email_verified_at = null;
}
$user->forceFill([
'name' => $data['name'],
'name_canonical' => $nameCanonical,
'email' => $data['email'],
'rank_id' => $data['rank_id'] ?? null,
])->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'roles' => $user->roles()->pluck('name')->values(),
]);
}
private function resolveAvatarUrl(User $user): ?string
{
if (!$user->avatar_path) {
return null;
}
return Storage::url($user->avatar_path);
}
} }

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();
}
} }

28
app/Models/Rank.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $users
*/
class Rank extends Model
{
protected $fillable = [
'name',
'badge_type',
'badge_text',
'badge_image_path',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

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

@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification; use Illuminate\Notifications\DatabaseNotification;
@@ -64,6 +65,10 @@ class User extends Authenticatable implements MustVerifyEmail
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
'name_canonical',
'avatar_path',
'location',
'rank_id',
'email', 'email',
'password', 'password',
]; ];
@@ -95,4 +100,14 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function rank()
{
return $this->belongsTo(Rank::class);
}
} }

View File

@@ -4,7 +4,7 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true)); define(constant_name: 'LARAVEL_START', value: microtime(as_float: true));
// Register the Composer autoloader... // Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php'; require __DIR__.'/vendor/autoload.php';
@@ -13,6 +13,6 @@ require __DIR__.'/vendor/autoload.php';
/** @var Application $app */ /** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php'; $app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput); $status = $app->handleCommand(input: new ArgvInput);
exit($status); exit($status);

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

@@ -23,8 +23,11 @@ class UserFactory extends Factory
*/ */
public function definition(): array public function definition(): array
{ {
$name = fake()->unique()->userName();
return [ return [
'name' => fake()->name(), 'name' => $name,
'name_canonical' => Str::lower($name),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name_canonical')->nullable()->after('name');
});
DB::table('users')
->whereNull('name_canonical')
->update(['name_canonical' => DB::raw('lower(name)')]);
Schema::table('users', function (Blueprint $table) {
$table->unique('name_canonical');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['name_canonical']);
$table->dropColumn('name_canonical');
});
}
};

View File

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

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ranks', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ranks');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('rank_id')->nullable()->after('avatar_path')->constrained('ranks')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('rank_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->string('badge_type')->default('text')->after('name');
$table->string('badge_text')->nullable()->after('badge_type');
$table->string('badge_image_path')->nullable()->after('badge_text');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn(['badge_type', 'badge_text', 'badge_image_path']);
});
}
};

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

@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
{ {
$this->call([ $this->call([
RoleSeeder::class, RoleSeeder::class,
RankSeeder::class,
UserSeeder::class, UserSeeder::class,
ForumSeeder::class, ForumSeeder::class,
ThreadSeeder::class, ThreadSeeder::class,

View File

@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Models\Rank;
use App\Models\User;
use Illuminate\Database\Seeder;
class RankSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$member = Rank::firstOrCreate(
['name' => 'Member'],
['badge_type' => 'text', 'badge_text' => 'Member']
);
$operator = Rank::firstOrCreate(
['name' => 'Operator'],
['badge_type' => 'text', 'badge_text' => 'Operator']
);
$moderator = Rank::firstOrCreate(
['name' => 'Moderator'],
['badge_type' => 'text', 'badge_text' => 'Moderator']
);
User::query()
->whereNull('rank_id')
->update(['rank_id' => $member->id]);
User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_ADMIN'))
->update(['rank_id' => $operator->id]);
User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_MODERATOR'))
->update(['rank_id' => $moderator->id]);
}
}

View File

@@ -4,6 +4,8 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\Rank;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
@@ -14,14 +16,29 @@ class UserSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
$adminRole = Role::where('name', 'ROLE_ADMIN')->first(); $adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
$userRole = Role::where('name', 'ROLE_USER')->first(); $userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first();
$operatorRank = Rank::where('name', 'Operator')->first();
$memberRank = Rank::where('name', 'Member')->first();
$admin = User::firstOrCreate( $admin = User::updateOrCreate(
['email' => 'tracer@24unix.net'], attributes: ['email' => 'tracer@24unix.net'],
[ values : [
'name' => 'tracer', 'name' => 'tracer',
'password' => Hash::make('password'), 'name_canonical' => Str::lower('tracer'),
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
'password' => Hash::make(value: 'password'),
'email_verified_at' => now(),
]
);
$micha = User::updateOrCreate(
attributes: ['email' => 'micha@24unix.net'],
values : [
'name' => 'Micha',
'name_canonical' => Str::lower('Micha'),
'rank_id' => $memberRank?->id,
'password' => Hash::make(value: 'password'),
'email_verified_at' => now(), 'email_verified_at' => now(),
] ]
); );
@@ -34,6 +51,10 @@ class UserSeeder extends Seeder
$admin->roles()->syncWithoutDetaching([$userRole->id]); $admin->roles()->syncWithoutDetaching([$userRole->id]);
} }
if ($userRole) {
$micha->roles()->syncWithoutDetaching([$userRole->id]);
}
$users = User::factory()->count(100)->create([ $users = User::factory()->count(100)->create([
'email_verified_at' => now(), 'email_verified_at' => now(),
]); ]);

View File

@@ -10,10 +10,19 @@ import Register from './pages/Register'
import Acp from './pages/Acp' import Acp from './pages/Acp'
import BoardIndex from './pages/BoardIndex' import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp' import Ucp from './pages/Ucp'
import Profile from './pages/Profile'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client' import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) { function PortalHeader({
userMenu,
isAuthenticated,
forumName,
logoUrl,
showHeaderName,
canAccessAcp,
canAccessMcp,
}) {
const { t } = useTranslation() const { t } = useTranslation()
const location = useLocation() const location = useLocation()
const [crumbs, setCrumbs] = useState([]) const [crumbs, setCrumbs] = useState([])
@@ -96,6 +105,33 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
} }
} }
if (location.pathname.startsWith('/acp')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.link_acp'), to: '/acp', current: true },
])
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 }])
} }
@@ -107,15 +143,17 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
}, [location.pathname, t]) }, [location.pathname, t])
return ( return (
<Container className="pt-2 pb-2 bb-portal-shell"> <Container fluid className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner"> <div className="bb-portal-banner">
<div className="bb-portal-brand"> <div className="bb-portal-brand">
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
{logoUrl && ( {logoUrl && (
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" /> <img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
)} )}
{(showHeaderName || !logoUrl) && ( {(showHeaderName || !logoUrl) && (
<div className="bb-portal-logo">{forumName || '24unix.net'}</div> <div className="bb-portal-logo">{forumName || '24unix.net'}</div>
)} )}
</Link>
</div> </div>
<div className="bb-portal-search"> <div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled /> <input type="text" placeholder={t('portal.search_placeholder')} disabled />
@@ -135,12 +173,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
<span> <span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')} <i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span> </span>
{isAuthenticated && canAccessAcp && (
<>
<Link to="/acp" className="bb-portal-link"> <Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')} <i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link> </Link>
</>
)}
{isAuthenticated && canAccessMcp && (
<span> <span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')} <i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span> </span>
)}
</div> </div>
</div> </div>
<div <div
@@ -197,7 +241,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
function AppShell() { function AppShell() {
const { t } = useTranslation() const { t } = useTranslation()
const { token, email, logout, isAdmin } = useAuth() const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
const [versionInfo, setVersionInfo] = useState(null) const [versionInfo, setVersionInfo] = useState(null)
const [theme, setTheme] = useState('auto') const [theme, setTheme] = useState('auto')
const [resolvedTheme, setResolvedTheme] = useState('light') const [resolvedTheme, setResolvedTheme] = useState('light')
@@ -403,7 +447,7 @@ function AppShell() {
<NavDropdown.Item as={Link} to="/ucp"> <NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')} <i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item> </NavDropdown.Item>
<NavDropdown.Item as={Link} to="/ucp"> <NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')} <i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item> </NavDropdown.Item>
<NavDropdown.Divider /> <NavDropdown.Divider />
@@ -413,6 +457,8 @@ function AppShell() {
</NavDropdown> </NavDropdown>
) : null ) : null
} }
canAccessAcp={isAdmin}
canAccessMcp={isModerator}
/> />
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
@@ -421,6 +467,7 @@ function AppShell() {
<Route path="/thread/:id" element={<ThreadView />} /> <Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} /> <Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route <Route
path="/ucp" path="/ucp"

View File

@@ -48,10 +48,10 @@ export async function getCollection(path) {
return data?.['hydra:member'] || [] return data?.['hydra:member'] || []
} }
export async function login(email, password) { export async function login(login, password) {
return apiFetch('/login', { return apiFetch('/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ login, password }),
}) })
} }
@@ -70,10 +70,45 @@ export async function listAllForums() {
return getCollection('/forums?pagination=false') return getCollection('/forums?pagination=false')
} }
export async function getCurrentUser() {
return apiFetch('/user/me')
}
export async function updateCurrentUser(payload) {
return apiFetch('/user/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function uploadAvatar(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/user/avatar', {
method: 'POST',
body,
})
}
export async function getUserProfile(id) {
return apiFetch(`/user/profile/${id}`)
}
export async function fetchVersion() { 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()
@@ -211,6 +246,53 @@ export async function listUsers() {
return getCollection('/users') return getCollection('/users')
} }
export async function listRanks() {
return getCollection('/ranks')
}
export async function updateUserRank(userId, rankId) {
return apiFetch(`/users/${userId}/rank`, {
method: 'PATCH',
body: JSON.stringify({ rank_id: rankId }),
})
}
export async function createRank(payload) {
return apiFetch('/ranks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRank(rankId, payload) {
return apiFetch(`/ranks/${rankId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRank(rankId) {
return apiFetch(`/ranks/${rankId}`, {
method: 'DELETE',
})
}
export async function uploadRankBadgeImage(rankId, file) {
const body = new FormData()
body.append('file', file)
return apiFetch(`/ranks/${rankId}/badge-image`, {
method: 'POST',
body,
})
}
export async function updateUser(userId, payload) {
return apiFetch(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function createThread({ title, body, forumId }) { export async function createThread({ title, body, forumId }) {
return apiFetch('/threads', { return apiFetch('/threads', {
method: 'POST', method: 'POST',

View 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>
)
}

View File

@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
userId: effectiveUserId, userId: effectiveUserId,
roles: effectiveRoles, roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'), isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
async login(emailInput, password) { isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
const data = await apiLogin(emailInput, password) async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token) localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || emailInput) localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) { if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id)) localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id)) setUserId(String(data.user_id))
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
setRoles([]) setRoles([])
} }
setToken(data.token) setToken(data.token)
setEmail(data.email || emailInput) setEmail(data.email || loginInput)
}, },
logout() { logout() {
localStorage.removeItem('speedbb_token') localStorage.removeItem('speedbb_token')
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
userId: effectiveUserId, userId: effectiveUserId,
roles: effectiveRoles, roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'), isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token), hasToken: Boolean(token),
}) })
}, [email, effectiveUserId, effectiveRoles, token]) }, [email, effectiveUserId, effectiveRoles, token])

View File

@@ -7,6 +7,7 @@
--bb-gold: #e4a634; --bb-gold: #e4a634;
--bb-peach: #f4c7a3; --bb-peach: #f4c7a3;
--bb-border: #e0d7c7; --bb-border: #e0d7c7;
--bb-shell-max: 1880px;
} }
* { * {
@@ -88,6 +89,328 @@ a {
padding: 1.2rem; padding: 1.2rem;
} }
.bb-thread {
display: flex;
flex-direction: column;
gap: 1.4rem;
}
.bb-thread-titlebar {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bb-thread-title {
margin: 0;
font-size: 1.6rem;
color: var(--bb-accent, #f29b3f);
}
.bb-thread-meta {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--bb-ink-muted);
font-size: 0.95rem;
}
.bb-thread-author {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-thread-date {
opacity: 0.8;
}
.bb-thread-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0.8rem;
border-radius: 10px;
border: 1px solid var(--bb-border);
background: #141822;
flex-wrap: wrap;
}
.bb-thread-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.bb-thread-toolbar .bb-accent-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
padding: 0.35rem 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
}
.bb-thread-icon-button {
border: 1px solid #2a2f3a;
background: #20252f;
color: #d0d6df;
width: 32px;
height: 32px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s ease, color 0.15s ease;
}
.bb-thread-icon-button:hover {
color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
}
.bb-thread-meta-right {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-thread-stats {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--bb-ink-muted);
font-size: 0.9rem;
}
.bb-thread-empty {
color: var(--bb-accent, #f29b3f);
margin-left: 0.6rem;
}
.bb-posts {
border: 1px solid var(--bb-border);
border-radius: 16px;
overflow: hidden;
background: #171b22;
}
.bb-post-row {
display: grid;
grid-template-columns: 260px 1fr;
border-top: 1px solid var(--bb-border);
}
.bb-post-row:first-child {
border-top: 0;
}
.bb-post-author {
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border-right: 1px solid var(--bb-border);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bb-post-avatar {
width: 150px;
height: 150px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
color: var(--bb-accent, #f29b3f);
font-size: 1.1rem;
overflow: hidden;
}
.bb-post-avatar i {
font-size: 4.4rem;
}
.bb-post-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-post-author-name {
font-weight: 600;
color: var(--bb-accent, #f29b3f);
}
.bb-post-author-role {
color: var(--bb-ink-muted);
font-size: 0.9rem;
margin-top: -0.2rem;
}
.bb-post-author-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.6rem;
border-radius: 6px;
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
color: #7b1f2a;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
width: fit-content;
overflow: hidden;
}
.bb-post-author-badge img {
width: auto;
height: 22px;
display: block;
}
.bb-post-author-badge span {
white-space: nowrap;
}
.bb-post-author-meta {
font-size: 0.85rem;
color: var(--bb-ink-muted);
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.3rem;
}
.bb-post-author-stat {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.bb-post-author-label {
color: var(--bb-ink-muted);
font-weight: 600;
}
.bb-post-author-value {
color: var(--bb-ink);
}
.bb-post-author-value i {
color: var(--bb-accent, #f29b3f);
font-size: 1.05rem;
}
.bb-post-author-contact .bb-post-author-value {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.bb-post-content {
padding: 1rem 1.35rem 1.2rem;
}
.bb-post-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.85rem;
color: var(--bb-ink-muted);
margin-bottom: 0.75rem;
}
.bb-post-header-meta {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.bb-post-topic {
font-weight: 600;
color: var(--bb-accent, #f29b3f);
}
.bb-post-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.bb-post-action {
width: 44px;
height: 44px;
border-radius: 6px;
border: 1px solid #2a2f3a;
background: #20252f;
color: #c7cdd7;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
transition: border-color 0.15s ease, color 0.15s ease;
}
.bb-post-action:hover {
color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
}
.bb-post-body {
white-space: pre-wrap;
color: var(--bb-ink);
line-height: 1.6;
}
.bb-thread-reply {
border: 1px solid var(--bb-border);
border-radius: 16px;
padding: 1.1rem 1.2rem 1.4rem;
background: #171b22;
}
.bb-thread-reply-title {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bb-accent, #f29b3f);
margin-bottom: 1rem;
font-size: 0.95rem;
}
.bb-thread-reply-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 992px) {
.bb-post-row {
grid-template-columns: 1fr;
}
.bb-post-author {
border-right: 0;
border-bottom: 1px solid var(--bb-border);
flex-direction: row;
align-items: center;
}
.bb-post-avatar {
width: 80px;
height: 80px;
}
}
.bb-forum-row { .bb-forum-row {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
@@ -513,8 +836,9 @@ a {
font-weight: 600; font-weight: 600;
} }
.bb-portal-shell { .container.bb-portal-shell,
max-width: 1400px; .container.bb-shell-container {
max-width: var(--bb-shell-max);
} }
.bb-portal-banner { .bb-portal-banner {
@@ -544,6 +868,13 @@ a {
font-weight: 700; font-weight: 700;
} }
.bb-portal-logo-link {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
.bb-portal-logo-image { .bb-portal-logo-image {
width: auto; width: auto;
height: auto; height: auto;
@@ -850,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;
@@ -953,6 +1311,56 @@ a {
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
.bb-avatar-preview {
width: 150px;
height: 150px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-profile {
display: flex;
gap: 1.2rem;
align-items: center;
}
.bb-profile-avatar {
width: 150px;
height: 150px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-profile-name {
font-size: 1.4rem;
font-weight: 700;
color: var(--bb-ink);
}
.bb-portal-list { .bb-portal-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -1031,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;
@@ -1060,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;
@@ -1069,11 +1551,33 @@ a {
} }
.bb-portal-user-avatar { .bb-portal-user-avatar {
width: 72px; width: 150px;
height: 72px; height: 150px;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.04)); background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.04));
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-portal-user-avatar img {
width: auto;
height: auto;
max-width: 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 {
@@ -1082,7 +1586,7 @@ a {
.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;
} }
@@ -1291,6 +1795,109 @@ a {
border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000); border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
} }
.bb-tree-action-group {
width: 176px;
}
.bb-tree-action-group .bb-action-group {
justify-content: flex-end;
}
.bb-rank-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.bb-rank-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(18, 23, 33, 0.8);
}
.bb-rank-main {
display: flex;
align-items: center;
gap: 0.6rem;
}
.bb-rank-main img {
height: 22px;
width: auto;
}
.bb-rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.5rem;
border-radius: 6px;
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
color: #7b1f2a;
font-weight: 700;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.bb-rank-badge-preview {
padding: 0.4rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 0.5rem;
}
.bb-rank-badge-preview img {
height: 28px;
width: auto;
display: block;
}
.bb-rank-actions {
display: inline-flex;
gap: 0.5rem;
}
.bb-user-search {
max-width: 320px;
}
.bb-sort-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
}
.bb-sort-label i {
font-size: 0.9rem;
color: var(--bb-ink-muted);
}
.rdt_TableCol_Sortable svg {
display: none;
}
.rdt_TableCol_Sortable .__rdt_custom_sort_icon__ {
display: none !important;
}
.bb-sort-hidden {
display: none;
}
.rdt_TableCol_Sortable .__rdt_custom_sort_icon__ i {
font-size: 0.9rem;
}
.bb-drag-handle { .bb-drag-handle {
font-size: 1.2rem; font-size: 1.2rem;
line-height: 1; line-height: 1;

View File

@@ -8,10 +8,17 @@ import {
deleteForum, deleteForum,
fetchSettings, fetchSettings,
listAllForums, listAllForums,
listRanks,
listUsers, listUsers,
reorderForums, reorderForums,
saveSetting, saveSetting,
saveSettings, saveSettings,
createRank,
deleteRank,
updateUserRank,
updateRank,
updateUser,
uploadRankBadgeImage,
uploadFavicon, uploadFavicon,
uploadLogo, uploadLogo,
updateForum, updateForum,
@@ -28,10 +35,33 @@ export default function Acp({ isAdmin }) {
const pendingOrder = useRef(null) const pendingOrder = useRef(null)
const [createType, setCreateType] = useState(null) const [createType, setCreateType] = useState(null)
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [userSearch, setUserSearch] = useState('')
const [usersLoading, setUsersLoading] = useState(false) const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('') const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1) const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10) const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
const [ranks, setRanks] = useState([])
const [ranksLoading, setRanksLoading] = useState(false)
const [ranksError, setRanksError] = useState('')
const [rankUpdatingId, setRankUpdatingId] = useState(null)
const [rankFormName, setRankFormName] = useState('')
const [rankFormType, setRankFormType] = useState('text')
const [rankFormText, setRankFormText] = useState('')
const [rankFormImage, setRankFormImage] = useState(null)
const [rankSaving, setRankSaving] = useState(false)
const [showRankModal, setShowRankModal] = useState(false)
const [rankEdit, setRankEdit] = useState({
id: null,
name: '',
badgeType: 'text',
badgeText: '',
badgeImageUrl: '',
})
const [rankEditImage, setRankEditImage] = useState(null)
const [showUserModal, setShowUserModal] = useState(false)
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
const [userSaving, setUserSaving] = useState(false)
const [generalSaving, setGeneralSaving] = useState(false) const [generalSaving, setGeneralSaving] = useState(false)
const [generalUploading, setGeneralUploading] = useState(false) const [generalUploading, setGeneralUploading] = useState(false)
const [generalError, setGeneralError] = useState('') const [generalError, setGeneralError] = useState('')
@@ -341,17 +371,93 @@ export default function Acp({ isAdmin }) {
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
const filteredUsers = useMemo(() => {
const term = userSearch.trim().toLowerCase()
if (!term) return users
return users.filter((user) =>
[user.name, user.email, user.rank?.name]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(term))
)
}, [users, userSearch])
const userColumns = useMemo( const userColumns = useMemo(
() => [ () => {
const iconFor = (id) => {
if (userSort.columnId !== id) {
return 'bi-arrow-down-up'
}
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
}
return [
{ {
name: t('user.name'), id: 'name',
name: (
<span className="bb-sort-label">
{t('user.name')}
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.name, selector: (row) => row.name,
sortable: true, sortable: true,
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
sensitivity: 'base',
}),
},
{
id: 'email',
name: (
<span className="bb-sort-label">
{t('user.email')}
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.email,
sortable: true,
}, },
{ {
name: t('user.email'), id: 'rank',
selector: (row) => row.email, name: (
<span className="bb-sort-label">
{t('user.rank')}
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
</span>
),
width: '220px',
sortable: true, sortable: true,
sortFunction: (a, b) =>
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
cell: (row) => (
<Form.Select
size="sm"
value={row.rank?.id ?? ''}
disabled={ranksLoading || rankUpdatingId === row.id}
onChange={async (event) => {
const nextRankId = event.target.value ? Number(event.target.value) : null
setRankUpdatingId(row.id)
try {
const updated = await updateUserRank(row.id, nextRankId)
setUsers((prev) =>
prev.map((user) =>
user.id === row.id ? { ...user, rank: updated.rank } : user
)
)
} catch (err) {
setUsersError(err.message)
} finally {
setRankUpdatingId(null)
}
}}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
),
}, },
{ {
name: '', name: '',
@@ -370,7 +476,16 @@ export default function Acp({ isAdmin }) {
<Button <Button
variant="dark" variant="dark"
title={t('user.edit')} title={t('user.edit')}
onClick={() => console.log('edit user', row)} onClick={() => {
setUserForm({
id: row.id,
name: row.name,
email: row.email,
rankId: row.rank?.id ?? '',
})
setShowUserModal(true)
setUsersError('')
}}
> >
<i className="bi bi-pencil" aria-hidden="true" /> <i className="bi bi-pencil" aria-hidden="true" />
</Button> </Button>
@@ -385,8 +500,9 @@ export default function Acp({ isAdmin }) {
</div> </div>
), ),
}, },
], ]
[t] },
[t, ranks, ranksLoading, rankUpdatingId, userSort]
) )
const userTableStyles = useMemo( const userTableStyles = useMemo(
() => ({ () => ({
@@ -536,6 +652,57 @@ export default function Acp({ isAdmin }) {
} }
}, [isAdmin]) }, [isAdmin])
const refreshRanks = async () => {
setRanksLoading(true)
setRanksError('')
try {
const data = await listRanks()
setRanks(data)
} catch (err) {
setRanksError(err.message)
} finally {
setRanksLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshRanks()
}
}, [isAdmin])
const handleCreateRank = async (event) => {
event.preventDefault()
if (!rankFormName.trim()) return
if (rankFormType === 'image' && !rankFormImage) {
setRanksError(t('rank.badge_image_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const created = await createRank({
name: rankFormName.trim(),
badge_type: rankFormType,
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
})
let next = created
if (rankFormType === 'image' && rankFormImage) {
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
next = { ...created, ...updated }
}
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
setRankFormName('')
setRankFormType('text')
setRankFormText('')
setRankFormImage(null)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}
const getParentId = (forum) => { const getParentId = (forum) => {
if (!forum.parent) return null if (!forum.parent) return null
if (typeof forum.parent === 'string') { if (typeof forum.parent === 'string') {
@@ -894,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">
@@ -905,7 +1072,8 @@ export default function Acp({ isAdmin }) {
> >
<i className="bi bi-arrow-down-up" aria-hidden="true" /> <i className="bi bi-arrow-down-up" aria-hidden="true" />
</span> </span>
<ButtonGroup size="sm" className="bb-action-group"> <div className="bb-tree-action-group">
<ButtonGroup size="sm" className="bb-action-group w-100">
{node.type === 'category' && ( {node.type === 'category' && (
<> <>
<Button <Button
@@ -933,6 +1101,7 @@ export default function Acp({ isAdmin }) {
</ButtonGroup> </ButtonGroup>
</div> </div>
</div> </div>
</div>
{node.children?.length > 0 && {node.children?.length > 0 &&
(!node.type || node.type !== 'category' || isExpanded(node.id)) && ( (!node.type || node.type !== 'category' || isExpanded(node.id)) && (
<div className="mb-2">{renderTree(node.children, depth + 1)}</div> <div className="mb-2">{renderTree(node.children, depth + 1)}</div>
@@ -942,7 +1111,7 @@ export default function Acp({ isAdmin }) {
if (!isAdmin) { if (!isAdmin) {
return ( return (
<Container className="py-5"> <Container fluid className="py-5">
<h2 className="mb-3">{t('acp.title')}</h2> <h2 className="mb-3">{t('acp.title')}</h2>
<p className="bb-muted">{t('acp.no_access')}</p> <p className="bb-muted">{t('acp.no_access')}</p>
</Container> </Container>
@@ -1296,17 +1465,23 @@ export default function Acp({ isAdmin }) {
</Tab> </Tab>
<Tab eventKey="users" title={t('acp.users')}> <Tab eventKey="users" title={t('acp.users')}>
{usersError && <p className="text-danger">{usersError}</p>} {usersError && <p className="text-danger">{usersError}</p>}
{ranksError && <p className="text-danger">{ranksError}</p>}
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>} {usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!usersLoading && ( {!usersLoading && (
<DataTable <DataTable
columns={userColumns} columns={userColumns}
data={users} data={filteredUsers}
pagination pagination
striped striped
highlightOnHover={themeMode !== 'dark'} highlightOnHover={themeMode !== 'dark'}
dense dense
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'} theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
customStyles={userTableStyles} customStyles={userTableStyles}
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
defaultSortFieldId="name"
onSort={(column, direction) => {
setUserSort({ columnId: column.id, direction })
}}
paginationComponentOptions={{ paginationComponentOptions={{
rowsPerPageText: t('table.rows_per_page'), rowsPerPageText: t('table.rows_per_page'),
rangeSeparatorText: t('table.range_separator'), rangeSeparatorText: t('table.range_separator'),
@@ -1318,7 +1493,147 @@ export default function Acp({ isAdmin }) {
setUsersPage(1) setUsersPage(1)
}} }}
paginationComponent={UsersPagination} paginationComponent={UsersPagination}
subHeader
subHeaderComponent={
<Form.Control
className="bb-user-search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder={t('user.search')}
/> />
}
/>
)}
</Tab>
<Tab eventKey="ranks" title={t('acp.ranks')}>
{ranksError && <p className="text-danger">{ranksError}</p>}
<Row className="g-3 align-items-end mb-3">
<Col md={6}>
<Form onSubmit={handleCreateRank}>
<Form.Group>
<Form.Label>{t('rank.name')}</Form.Label>
<Form.Control
value={rankFormName}
onChange={(event) => setRankFormName(event.target.value)}
placeholder={t('rank.name_placeholder')}
disabled={rankSaving}
/>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('rank.badge_type')}</Form.Label>
<div className="d-flex gap-3">
<Form.Check
type="radio"
id="rank-badge-text"
name="rankBadgeType"
label={t('rank.badge_text')}
checked={rankFormType === 'text'}
onChange={() => setRankFormType('text')}
/>
<Form.Check
type="radio"
id="rank-badge-image"
name="rankBadgeType"
label={t('rank.badge_image')}
checked={rankFormType === 'image'}
onChange={() => setRankFormType('image')}
/>
</div>
</Form.Group>
{rankFormType === 'text' && (
<Form.Group className="mt-3">
<Form.Label>{t('rank.badge_text')}</Form.Label>
<Form.Control
value={rankFormText}
onChange={(event) => setRankFormText(event.target.value)}
placeholder={t('rank.badge_text_placeholder')}
disabled={rankSaving}
/>
</Form.Group>
)}
{rankFormType === 'image' && (
<Form.Group className="mt-3">
<Form.Label>{t('rank.badge_image')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
disabled={rankSaving}
/>
</Form.Group>
)}
</Form>
</Col>
<Col md="auto">
<Button
type="button"
className="bb-accent-button"
onClick={handleCreateRank}
disabled={rankSaving || !rankFormName.trim()}
>
{rankSaving ? t('form.saving') : t('rank.create')}
</Button>
</Col>
</Row>
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!ranksLoading && ranks.length === 0 && (
<p className="bb-muted">{t('rank.empty')}</p>
)}
{!ranksLoading && ranks.length > 0 && (
<div className="bb-rank-list">
{ranks.map((rank) => (
<div key={rank.id} className="bb-rank-row">
<div className="bb-rank-main">
<span>{rank.name}</span>
{rank.badge_type === 'image' && rank.badge_image_url && (
<img src={rank.badge_image_url} alt="" />
)}
{rank.badge_type !== 'image' && rank.badge_text && (
<span className="bb-rank-badge">{rank.badge_text}</span>
)}
</div>
<div className="bb-rank-actions">
<Button
size="sm"
variant="dark"
onClick={() => {
setRankEdit({
id: rank.id,
name: rank.name,
badgeType: rank.badge_type || 'text',
badgeText: rank.badge_text || '',
badgeImageUrl: rank.badge_image_url || '',
})
setRankEditImage(null)
setShowRankModal(true)
setRanksError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
onClick={async () => {
if (!window.confirm(t('rank.delete_confirm'))) return
setRankSaving(true)
setRanksError('')
try {
await deleteRank(rank.id)
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)} )}
</Tab> </Tab>
</Tabs> </Tabs>
@@ -1408,6 +1723,221 @@ export default function Acp({ isAdmin }) {
</Form> </Form>
</Modal.Body> </Modal.Body>
</Modal> </Modal>
<Modal
show={showUserModal}
onHide={() => setShowUserModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('user.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{usersError && <p className="text-danger">{usersError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
setUserSaving(true)
setUsersError('')
try {
const payload = {
name: userForm.name,
email: userForm.email,
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
}
const updated = await updateUser(userForm.id, payload)
setUsers((prev) =>
prev.map((user) =>
user.id === updated.id ? { ...user, ...updated } : user
)
)
setShowUserModal(false)
} catch (err) {
setUsersError(err.message)
} finally {
setUserSaving(false)
}
}}
>
<Form.Group className="mb-3">
<Form.Label>{t('form.username')}</Form.Label>
<Form.Control
value={userForm.name}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={userForm.email}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, email: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('user.rank')}</Form.Label>
<Form.Select
value={userForm.rankId ?? ''}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
}
disabled={ranksLoading}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowUserModal(false)}
disabled={userSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
{userSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showRankModal}
onHide={() => setShowRankModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('rank.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{ranksError && <p className="text-danger">{ranksError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
if (!rankEdit.name.trim()) return
if (rankEdit.badgeType === 'text' && !rankEdit.badgeText.trim()) {
setRanksError(t('rank.badge_text_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const updated = await updateRank(rankEdit.id, {
name: rankEdit.name.trim(),
badge_type: rankEdit.badgeType,
badge_text:
rankEdit.badgeType === 'text'
? rankEdit.badgeText.trim() || rankEdit.name.trim()
: null,
})
let next = updated
if (rankEdit.badgeType === 'image' && rankEditImage) {
const upload = await uploadRankBadgeImage(rankEdit.id, rankEditImage)
next = { ...updated, ...upload }
}
setRanks((prev) =>
prev
.map((item) => (item.id === next.id ? { ...item, ...next } : item))
.sort((a, b) => a.name.localeCompare(b.name))
)
setShowRankModal(false)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<Form.Group className="mb-3">
<Form.Label>{t('rank.name')}</Form.Label>
<Form.Control
value={rankEdit.name}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_type')}</Form.Label>
<div className="d-flex gap-3">
<Form.Check
type="radio"
id="rank-edit-badge-text"
name="rankEditBadgeType"
label={t('rank.badge_text')}
checked={rankEdit.badgeType === 'text'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
}
/>
<Form.Check
type="radio"
id="rank-edit-badge-image"
name="rankEditBadgeType"
label={t('rank.badge_image')}
checked={rankEdit.badgeType === 'image'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
}
/>
</div>
</Form.Group>
{rankEdit.badgeType === 'text' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_text')}</Form.Label>
<Form.Control
value={rankEdit.badgeText}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
}
placeholder={t('rank.badge_text_placeholder')}
required
/>
</Form.Group>
)}
{rankEdit.badgeType === 'image' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_image')}</Form.Label>
{rankEdit.badgeImageUrl && !rankEditImage && (
<div className="bb-rank-badge-preview">
<img src={rankEdit.badgeImageUrl} alt="" />
</div>
)}
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
/>
</Form.Group>
)}
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRankModal(false)}
disabled={rankSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
{rankSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
</Container> </Container>
) )
} }

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,16 +130,34 @@ 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">
{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> <span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div> </div>
</div> </div>
)) ))
return ( return (
<Container className="py-4 bb-portal-shell"> <Container fluid className="py-4 bb-portal-shell">
{loading && <p className="bb-muted">{t('home.loading')}</p>} {loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && ( {!loading && forumTree.length === 0 && (

View File

@@ -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">
{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> <span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div> </div>
</div> </div>
)) ))
@@ -96,7 +113,7 @@ export default function ForumView() {
} }
return ( return (
<Container className="py-5"> <Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>} {loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
{forum && ( {forum && (
@@ -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>
</> </>

View File

@@ -1,30 +1,61 @@
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 { 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'
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 { token, roles, email } = useAuth()
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
listAllForums() let active = true
.then(setForums) setLoadingForums(true)
.catch((err) => setError(err.message)) setLoadingThreads(true)
.finally(() => setLoadingForums(false)) setLoadingStats(true)
}, []) setError('')
useEffect(() => { fetchPortalSummary()
listThreads() .then((data) => {
.then(setThreads) if (!active) return
.catch((err) => setError(err.message)) setForums(data?.forums || [])
.finally(() => setLoadingThreads(false)) setThreads(data?.threads || [])
}, []) setStats({
threads: data?.stats?.threads ?? 0,
posts: data?.stats?.posts ?? 0,
users: data?.stats?.users ?? 0,
})
setProfile(data?.profile || null)
})
.catch((err) => {
if (!active) return
setError(err.message)
setForums([])
setThreads([])
setStats({ threads: 0, posts: 0, users: 0 })
setProfile(null)
})
.finally(() => {
if (!active) return
setLoadingForums(false)
setLoadingThreads(false)
setLoadingStats(false)
})
return () => {
active = false
}
}, [token])
const getParentId = (forum) => { const getParentId = (forum) => {
if (!forum.parent) return null if (!forum.parent) return null
@@ -79,6 +110,13 @@ export default function Home() {
.slice(0, 12) .slice(0, 12)
}, [threads]) }, [threads])
const roleLabel = useMemo(() => {
if (!roles?.length) return t('portal.user_role_member')
if (roles.includes('ROLE_ADMIN')) return t('portal.user_role_operator')
if (roles.includes('ROLE_MODERATOR')) return t('portal.user_role_moderator')
return t('portal.user_role_member')
}, [roles, t])
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('/')
@@ -107,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>
@@ -118,7 +156,7 @@ export default function Home() {
)) ))
return ( return (
<Container className="pb-4 bb-portal-shell"> <Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout"> <div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left"> <aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card"> <div className="bb-portal-card">
@@ -134,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>
@@ -159,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>
)} )}
@@ -205,9 +217,23 @@ 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">
<div className="bb-portal-user-name">tracer</div> {profile?.avatar_url ? (
<div className="bb-portal-user-role">Operator</div> <img src={profile.avatar_url} alt="" />
) : (
<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 className="bb-portal-user-role">{roleLabel}</div>
</div> </div>
<ul className="bb-portal-list"> <ul className="bb-portal-list">
<li>{t('portal.user_new_posts')}</li> <li>{t('portal.user_new_posts')}</li>

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
export default function Login() { export default function Login() {
const { login } = useAuth() const { login } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [email, setEmail] = useState('') const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -18,7 +18,7 @@ export default function Login() {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
await login(email, password) await login(loginValue, password)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@@ -28,7 +28,7 @@ export default function Login() {
} }
return ( return (
<Container className="py-5"> <Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}> <Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body> <Card.Body>
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title> <Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
@@ -36,11 +36,12 @@ export default function Login() {
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label> <Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control <Form.Control
type="email" type="text"
value={email} value={loginValue}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')}
required required
/> />
</Form.Group> </Form.Group>

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { getUserProfile } from '../api/client'
export default function Profile() {
const { id } = useParams()
const { t } = useTranslation()
const [profile, setProfile] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
setLoading(true)
setError('')
getUserProfile(id)
.then((data) => {
if (!active) return
setProfile(data)
})
.catch((err) => {
if (!active) return
setError(err.message)
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [id])
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{profile && (
<div className="bb-profile">
<div className="bb-profile-avatar">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-profile-meta">
<div className="bb-profile-name">{profile.name}</div>
{profile.created_at && (
<div className="bb-muted">
{t('profile.registered')} {profile.created_at.slice(0, 10)}
</div>
)}
</div>
</div>
)}
</div>
</Container>
)
}

View File

@@ -33,7 +33,7 @@ export default function Register() {
} }
return ( return (
<Container className="py-5"> <Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}> <Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body> <Card.Body>
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title> <Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap' import { Button, Container, Form } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { createPost, getThread, listPostsByThread } from '../api/client' import { createPost, getThread, listPostsByThread } from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@@ -15,6 +15,7 @@ export default function ThreadView() {
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const replyRef = useRef(null)
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
@@ -27,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)
@@ -43,46 +56,198 @@ export default function ThreadView() {
} }
} }
const replyCount = posts.length
const formatDate = (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())
return `${day}.${month}.${year}`
}
const allPosts = useMemo(() => {
if (!thread) return posts
const rootPost = {
id: `thread-${thread.id}`,
body: thread.body,
created_at: thread.created_at,
user_name: thread.user_name,
user_avatar_url: thread.user_avatar_url,
user_posts_count: thread.user_posts_count,
user_created_at: thread.user_created_at,
user_location: thread.user_location,
user_rank_name: thread.user_rank_name,
user_rank_badge_type: thread.user_rank_badge_type,
user_rank_badge_text: thread.user_rank_badge_text,
user_rank_badge_url: thread.user_rank_badge_url,
isRoot: true,
}
return [rootPost, ...posts]
}, [posts, thread])
const handleJumpToReply = () => {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const totalPosts = allPosts.length
return ( return (
<Container className="py-5"> <Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>} {loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
{thread && ( {thread && (
<> <div className="bb-thread">
<div className="bb-hero mb-4"> <div className="bb-thread-titlebar">
<p className="bb-chip">{t('thread.label')}</p> <h1 className="bb-thread-title">{thread.title}</h1>
<h2 className="mt-3">{thread.title}</h2> <div className="bb-thread-meta">
<p className="bb-muted mb-2">{thread.body}</p> <span>{t('thread.by')}</span>
{thread.forum && ( <span className="bb-thread-author">
<p className="bb-muted mb-0"> {thread.user_name || t('thread.anonymous')}
{t('thread.category')}{' '} </span>
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}> {thread.created_at && (
{thread.forum.name || t('thread.back_to_category')} <span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
</Link>
</p>
)} )}
</div> </div>
</div>
<Row className="g-4"> <div className="bb-thread-toolbar">
<Col lg={7}> <div className="bb-thread-actions">
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4> <Button className="bb-accent-button" onClick={handleJumpToReply}>
{posts.length === 0 && ( <i className="bi bi-reply-fill" aria-hidden="true" />
<p className="bb-muted">{t('thread.empty')}</p> <span>{t('form.post_reply')}</span>
</Button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
<i className="bi bi-wrench" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
<i className="bi bi-gear" aria-hidden="true" />
</button>
</div>
<div className="bb-thread-meta-right">
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
<span></span>
<span>Page 1 of 1</span>
</div>
</div>
<div className="bb-posts">
{allPosts.map((post, index) => {
const authorName = post.author?.username
|| post.user_name
|| post.author_name
|| t('thread.anonymous')
const topicLabel = thread?.title
? post.isRoot
? thread.title
: `${t('thread.reply_prefix')} ${thread.title}`
: ''
const postNumber = index + 1
return (
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)} )}
{posts.map((post) => ( </div>
<Card className="bb-card mb-3" key={post.id}> <div className="bb-post-author-name">{authorName}</div>
<Card.Body> <div className="bb-post-author-role">
<Card.Text>{post.body}</Card.Text> {post.user_rank_name || ''}
<small className="bb-muted"> </div>
{post.author?.username || t('thread.anonymous')} {(post.user_rank_badge_text || post.user_rank_badge_url) && (
</small> <div className="bb-post-author-badge">
</Card.Body> {post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
</Card> <img src={post.user_rank_badge_url} alt="" />
))} ) : (
</Col> <span>{post.user_rank_badge_text}</span>
<Col lg={5}> )}
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4> </div>
<div className="bb-form"> )}
<div className="bb-post-author-meta">
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.posts')}:</span>
<span className="bb-post-author-value">
{post.user_posts_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.registered')}:</span>
<span className="bb-post-author-value">
{formatDate(post.user_created_at)}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.location')}:</span>
<span className="bb-post-author-value">
{post.user_location || '-'}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks given:</span>
<span className="bb-post-author-value">7</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks received:</span>
<span className="bb-post-author-value">5</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
</div>
</aside>
<div className="bb-post-content">
<div className="bb-post-header">
<div className="bb-post-header-meta">
{topicLabel && (
<span className="bb-post-topic">
#{postNumber} {topicLabel}
</span>
)}
<span>{t('thread.by')} {authorName}</span>
{post.created_at && (
<span>{post.created_at.slice(0, 10)}</span>
)}
</div>
<div className="bb-post-actions">
<button type="button" className="bb-post-action" aria-label="Edit post">
<i className="bi bi-pencil" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Delete post">
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Report post">
<i className="bi bi-exclamation-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Post info">
<i className="bi bi-info-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Quote post">
<i className="bi bi-quote" aria-hidden="true" />
</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 className="bb-post-body">{post.body}</div>
</div>
</article>
)
})}
</div>
<div className="bb-thread-reply" ref={replyRef}>
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
{!token && ( {!token && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p> <p className="bb-muted mb-3">{t('thread.login_hint')}</p>
)} )}
@@ -91,7 +256,7 @@ export default function ThreadView() {
<Form.Label>{t('form.message')}</Form.Label> <Form.Label>{t('form.message')}</Form.Label>
<Form.Control <Form.Control
as="textarea" as="textarea"
rows={5} rows={6}
placeholder={t('form.reply_placeholder')} placeholder={t('form.reply_placeholder')}
value={body} value={body}
onChange={(event) => setBody(event.target.value)} onChange={(event) => setBody(event.target.value)}
@@ -99,14 +264,14 @@ export default function ThreadView() {
required required
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={!token || saving}> <div className="bb-thread-reply-actions">
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')} {saving ? t('form.posting') : t('form.post_reply')}
</Button> </Button>
</div>
</Form> </Form>
</div> </div>
</Col> </div>
</Row>
</>
)} )}
</Container> </Container>
) )

View File

@@ -1,9 +1,39 @@
import { Container, Form, Row, Col } from 'react-bootstrap' import { useEffect, useState } from 'react'
import { Container, Form, Row, Col, Button } from 'react-bootstrap'
import { getCurrentUser, updateCurrentUser, uploadAvatar } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) { export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system' const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('')
const [location, setLocation] = useState('')
const [profileError, setProfileError] = useState('')
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
useEffect(() => {
if (!token) return
let active = true
getCurrentUser()
.then((data) => {
if (!active) return
setAvatarPreview(data?.avatar_url || '')
setLocation(data?.location || '')
})
.catch(() => {
if (active) setAvatarPreview('')
})
return () => {
active = false
}
}, [token])
const handleLanguageChange = (event) => { const handleLanguageChange = (event) => {
const locale = event.target.value const locale = event.target.value
@@ -12,7 +42,85 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
} }
return ( return (
<Container className="py-5 bb-portal-shell"> <Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card mb-4">
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
<Row className="g-3 align-items-center">
<Col md="auto">
<div className="bb-avatar-preview">
{avatarPreview ? (
<img src={avatarPreview} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
</Col>
<Col>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<Form.Group>
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
disabled={!token || avatarUploading}
onChange={async (event) => {
const file = event.target.files?.[0]
if (!file) return
setAvatarError('')
setAvatarUploading(true)
try {
const response = await uploadAvatar(file)
setAvatarPreview(response.url)
} catch (err) {
setAvatarError(err.message)
} finally {
setAvatarUploading(false)
}
}}
/>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</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>
</Row>
</div>
<div className="bb-portal-card"> <div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div> <div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p> <p className="bb-muted mb-4">{t('ucp.intro')}</p>

View File

@@ -44,6 +44,7 @@
"acp.show_header_name": "Forenname im Header anzeigen", "acp.show_header_name": "Forenname im Header anzeigen",
"acp.add_category": "Kategorie hinzufügen", "acp.add_category": "Kategorie hinzufügen",
"acp.add_forum": "Forum hinzufügen", "acp.add_forum": "Forum hinzufügen",
"acp.ranks": "Ränge",
"acp.forums_parent_root": "Wurzel (kein Parent)", "acp.forums_parent_root": "Wurzel (kein Parent)",
"acp.forums_tree": "Forenbaum", "acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ", "acp.forums_type": "Typ",
@@ -60,6 +61,8 @@
"acp.users": "Benutzer", "acp.users": "Benutzer",
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.", "auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
"auth.login_title": "Anmelden", "auth.login_title": "Anmelden",
"auth.login_identifier": "E-Mail oder Benutzername",
"auth.login_placeholder": "name@example.com oder benutzername",
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.", "auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.", "auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
"auth.register_title": "Konto erstellen", "auth.register_title": "Konto erstellen",
@@ -87,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",
@@ -99,6 +101,22 @@
"user.id": "ID", "user.id": "ID",
"user.name": "Name", "user.name": "Name",
"user.email": "E-Mail", "user.email": "E-Mail",
"user.rank": "Rang",
"user.rank_unassigned": "Nicht zugewiesen",
"user.edit_title": "Benutzer bearbeiten",
"user.search": "Benutzer suchen...",
"rank.name": "Rangname",
"rank.name_placeholder": "z. B. Operator",
"rank.create": "Rang erstellen",
"rank.edit_title": "Rang bearbeiten",
"rank.badge_type": "Badge-Typ",
"rank.badge_text": "Text-Badge",
"rank.badge_image": "Bild-Badge",
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
"rank.badge_text_required": "Badge-Text ist erforderlich.",
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
"rank.delete_confirm": "Diesen Rang löschen?",
"rank.empty": "Noch keine Ränge vorhanden.",
"user.roles": "Rollen", "user.roles": "Rollen",
"user.actions": "Aktionen", "user.actions": "Aktionen",
"user.impersonate": "Imitieren", "user.impersonate": "Imitieren",
@@ -136,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ü",
@@ -148,8 +168,22 @@
"portal.user_control_panel": "Benutzerkontrollzentrum", "portal.user_control_panel": "Benutzerkontrollzentrum",
"portal.user_profile": "Profil", "portal.user_profile": "Profil",
"portal.user_logout": "Logout", "portal.user_logout": "Logout",
"portal.user_role_operator": "Operator",
"portal.user_role_moderator": "Moderator",
"portal.user_role_member": "Mitglied",
"portal.advertisement": "Werbung", "portal.advertisement": "Werbung",
"profile.title": "Profil",
"profile.loading": "Profil wird geladen...",
"profile.registered": "Registriert:",
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.", "ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
"ucp.profile": "Profil",
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
"ucp.avatar_label": "Profilbild",
"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.",
@@ -161,6 +195,10 @@
"thread.label": "Thread", "thread.label": "Thread",
"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.location": "Wohnort",
"thread.reply_prefix": "Aw:",
"thread.registered": "Registriert",
"thread.replies": "Antworten", "thread.replies": "Antworten",
"thread.views": "Zugriffe", "thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag", "thread.last_post": "Letzter Beitrag",

View File

@@ -44,6 +44,7 @@
"acp.show_header_name": "Display name in header", "acp.show_header_name": "Display name in header",
"acp.add_category": "Add category", "acp.add_category": "Add category",
"acp.add_forum": "Add forum", "acp.add_forum": "Add forum",
"acp.ranks": "Ranks",
"acp.forums_parent_root": "Root (no parent)", "acp.forums_parent_root": "Root (no parent)",
"acp.forums_tree": "Forum tree", "acp.forums_tree": "Forum tree",
"acp.forums_type": "Type", "acp.forums_type": "Type",
@@ -60,6 +61,8 @@
"acp.users": "Users", "acp.users": "Users",
"auth.login_hint": "Access your account to start new threads and reply.", "auth.login_hint": "Access your account to start new threads and reply.",
"auth.login_title": "Log in", "auth.login_title": "Log in",
"auth.login_identifier": "Email or username",
"auth.login_placeholder": "name@example.com or username",
"auth.register_hint": "Register with an email and a unique username.", "auth.register_hint": "Register with an email and a unique username.",
"auth.verify_notice": "Check your email to verify your account before logging in.", "auth.verify_notice": "Check your email to verify your account before logging in.",
"auth.register_title": "Create account", "auth.register_title": "Create account",
@@ -87,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",
@@ -99,6 +101,22 @@
"user.id": "ID", "user.id": "ID",
"user.name": "Name", "user.name": "Name",
"user.email": "Email", "user.email": "Email",
"user.rank": "Rank",
"user.rank_unassigned": "Unassigned",
"user.edit_title": "Edit user",
"user.search": "Search users...",
"rank.name": "Rank name",
"rank.name_placeholder": "e.g. Operator",
"rank.create": "Create rank",
"rank.edit_title": "Edit rank",
"rank.badge_type": "Badge type",
"rank.badge_text": "Text badge",
"rank.badge_image": "Image badge",
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
"rank.badge_text_required": "Badge text is required.",
"rank.badge_image_required": "Badge image is required.",
"rank.delete_confirm": "Delete this rank?",
"rank.empty": "No ranks created yet.",
"user.roles": "Roles", "user.roles": "Roles",
"user.actions": "Actions", "user.actions": "Actions",
"user.impersonate": "Impersonate", "user.impersonate": "Impersonate",
@@ -136,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",
@@ -148,8 +168,22 @@
"portal.user_control_panel": "User Control Panel", "portal.user_control_panel": "User Control Panel",
"portal.user_profile": "Profile", "portal.user_profile": "Profile",
"portal.user_logout": "Logout", "portal.user_logout": "Logout",
"portal.user_role_operator": "Operator",
"portal.user_role_moderator": "Moderator",
"portal.user_role_member": "Member",
"portal.advertisement": "Advertisement", "portal.advertisement": "Advertisement",
"profile.title": "Profile",
"profile.loading": "Loading profile...",
"profile.registered": "Registered:",
"ucp.intro": "Manage your basic preferences for the forum.", "ucp.intro": "Manage your basic preferences for the forum.",
"ucp.profile": "Profile",
"ucp.profile_hint": "Update the avatar shown next to your posts.",
"ucp.avatar_label": "Profile image",
"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.",
@@ -161,6 +195,10 @@
"thread.label": "Thread", "thread.label": "Thread",
"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.location": "Location",
"thread.reply_prefix": "Re:",
"thread.registered": "Registered",
"thread.replies": "Replies", "thread.replies": "Replies",
"thread.views": "Views", "thread.views": "Views",
"thread.last_post": "Last post", "thread.last_post": "Last post",

View File

@@ -3,20 +3,31 @@
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;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\VersionController; use App\Http\Controllers\VersionController;
use App\Http\Controllers\RankController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']); Route::post('/register', [AuthController::class, 'register']);
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('guest');
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('guest');
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware('signed')
->name('verification.verify');
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); Route::post('/logout', [AuthController::class, 'logout'])->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');
@@ -24,8 +35,19 @@ Route::get('/user-settings', [UserSettingController::class, 'index'])->middlewar
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum'); Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum'); Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum'); Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
Route::get('/i18n/{locale}', I18nController::class); 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::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::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
Route::get('/forums', [ForumController::class, 'index']); Route::get('/forums', [ForumController::class, 'index']);
Route::get('/forums/{forum}', [ForumController::class, 'show']); Route::get('/forums/{forum}', [ForumController::class, 'show']);

View File

@@ -3,5 +3,7 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::view('/', 'app'); Route::view('/', 'app');
Route::view('/login', 'app')->name('login');
Route::view('/reset-password', 'app')->name('password.reset');
Route::view('/{any}', 'app')->where('any', '^(?!api).*$'); Route::view('/{any}', 'app')->where('any', '^(?!api).*$');

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB