From 073c81012b0451f4636148b29ce8ae3b1ac9af8b Mon Sep 17 00:00:00 2001 From: Micha Date: Sun, 18 Jan 2026 15:52:53 +0100 Subject: [PATCH] feat: add installer, ranks/groups enhancements, and founder protections --- .gitignore | 2 + app/Http/Controllers/ForumController.php | 57 +- app/Http/Controllers/InstallerController.php | 140 + app/Http/Controllers/PortalController.php | 37 +- app/Http/Controllers/PostController.php | 32 +- app/Http/Controllers/PostThankController.php | 122 + app/Http/Controllers/RankController.php | 24 +- app/Http/Controllers/RoleController.php | 141 + app/Http/Controllers/ThreadController.php | 53 +- app/Http/Controllers/UserController.php | 80 + app/Models/Post.php | 6 + app/Models/PostThank.php | 24 + app/Models/Rank.php | 1 + app/Models/Role.php | 1 + app/Models/User.php | 11 + ..._01_13_020000_create_post_thanks_table.php | 25 + ..._01_17_000000_add_color_to_ranks_table.php | 28 + ...2026_01_18_000000_normalize_role_names.php | 54 + ..._01_18_010000_add_color_to_roles_table.php | 28 + public/index.php | 17 + resources/js/App.jsx | 882 ++-- resources/js/api/client.js | 406 +- resources/js/components/PortalTopicRow.jsx | 180 +- resources/js/context/AuthContext.jsx | 166 +- resources/js/i18n.js | 32 +- resources/js/index.css | 227 +- resources/js/main.jsx | 6 +- resources/js/pages/Acp.jsx | 4418 ++++++++++------- resources/js/pages/BoardIndex.jsx | 423 +- resources/js/pages/ForumView.jsx | 475 +- resources/js/pages/Home.jsx | 466 +- resources/js/pages/Login.jsx | 108 +- resources/js/pages/Profile.jsx | 217 +- resources/js/pages/Register.jsx | 138 +- resources/js/pages/ThreadView.jsx | 539 +- resources/js/pages/Ucp.jsx | 330 +- resources/lang/de.json | 26 + resources/lang/en.json | 26 + resources/views/app.blade.php | 1 + resources/views/installer-success.blade.php | 55 + resources/views/installer.blade.php | 165 + routes/api.php | 10 + routes/web.php | 36 +- 43 files changed, 6176 insertions(+), 4039 deletions(-) create mode 100644 app/Http/Controllers/InstallerController.php create mode 100644 app/Http/Controllers/PostThankController.php create mode 100644 app/Http/Controllers/RoleController.php create mode 100644 app/Models/PostThank.php create mode 100644 database/migrations/2026_01_13_020000_create_post_thanks_table.php create mode 100644 database/migrations/2026_01_17_000000_add_color_to_ranks_table.php create mode 100644 database/migrations/2026_01_18_000000_normalize_role_names.php create mode 100644 database/migrations/2026_01_18_010000_add_color_to_roles_table.php create mode 100644 resources/views/installer-success.blade.php create mode 100644 resources/views/installer.blade.php diff --git a/.gitignore b/.gitignore index 01e91c1..05e2aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ /public/build /public/hot /public/storage +/storage/app +/storage/framework /storage/*.key /storage/pail /storage/framework/views/*.php diff --git a/app/Http/Controllers/ForumController.php b/app/Http/Controllers/ForumController.php index 2f019ea..597c810 100644 --- a/app/Http/Controllers/ForumController.php +++ b/app/Http/Controllers/ForumController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Forum; use App\Models\Post; +use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -14,31 +15,31 @@ class ForumController extends Controller { $query = Forum::query() ->withoutTrashed() - ->withCount(['threads', 'posts']) - ->withSum('threads', 'views_count'); + ->withCount(relations: ['threads', 'posts']) + ->withSum(relation: 'threads', column: 'views_count'); - $parentParam = $request->query('parent'); - if (is_array($parentParam) && array_key_exists('exists', $parentParam)) { - $exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $parentParam = $request->query(key: 'parent'); + if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) { + $exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE); if ($exists === false) { - $query->whereNull('parent_id'); + $query->whereNull(columns: 'parent_id'); } elseif ($exists === true) { - $query->whereNotNull('parent_id'); + $query->whereNotNull(columns: 'parent_id'); } - } elseif (is_string($parentParam)) { - $parentId = $this->parseIriId($parentParam); + } elseif (is_string(value: $parentParam)) { + $parentId = $this->parseIriId(value: $parentParam); if ($parentId !== null) { - $query->where('parent_id', $parentId); + $query->where(column: 'parent_id', operator: $parentId); } } - if ($request->filled('type')) { - $query->where('type', $request->query('type')); + if ($request->filled(key: 'type')) { + $query->where(column: 'type', operator: $request->query(key: 'type')); } $forums = $query - ->orderBy('position') - ->orderBy('name') + ->orderBy(column: 'position') + ->orderBy(column: 'name') ->get(); $forumIds = $forums->pluck('id')->all(); @@ -216,6 +217,8 @@ class ForumController extends Controller 'last_post_at' => $lastPost?->created_at?->toIso8601String(), 'last_post_user_id' => $lastPost?->user_id, 'last_post_user_name' => $lastPost?->user?->name, + 'last_post_user_rank_color' => $lastPost?->user?->rank?->color, + 'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user), 'created_at' => $forum->created_at?->toIso8601String(), 'updated_at' => $forum->updated_at?->toIso8601String(), ]; @@ -234,7 +237,7 @@ class ForumController extends Controller ->whereNull('posts.deleted_at') ->whereNull('threads.deleted_at') ->orderByDesc('posts.created_at') - ->with('user') + ->with(['user.rank', 'user.roles']) ->get(); $byForum = []; @@ -256,8 +259,28 @@ class ForumController extends Controller ->where('threads.forum_id', $forumId) ->whereNull('posts.deleted_at') ->whereNull('threads.deleted_at') - ->orderByDesc('posts.created_at') - ->with('user') + ->orderByDesc(column: 'posts.created_at') + ->with(relations: ['user.rank', 'user.roles']) ->first(); } + + private function resolveGroupColor(?User $user): ?string + { + if (!$user) { + return null; + } + + $roles = $user->roles; + if (!$roles) { + return null; + } + + foreach ($roles->sortBy(callback: 'name') as $role) { + if (!empty($role->color)) { + return $role->color; + } + } + + return null; + } } diff --git a/app/Http/Controllers/InstallerController.php b/app/Http/Controllers/InstallerController.php new file mode 100644 index 0000000..c1de03d --- /dev/null +++ b/app/Http/Controllers/InstallerController.php @@ -0,0 +1,140 @@ +envExists()) { + return redirect('/'); + } + + return view('installer', [ + 'appUrl' => $request->getSchemeAndHttpHost(), + ]); + } + + public function store(Request $request): View|RedirectResponse + { + if ($this->envExists()) { + return redirect('/'); + } + + $data = $request->validate([ + 'app_url' => ['required', 'url'], + 'db_host' => ['required', 'string', 'max:255'], + 'db_port' => ['nullable', 'integer'], + 'db_database' => ['required', 'string', 'max:255'], + 'db_username' => ['required', 'string', 'max:255'], + 'db_password' => ['nullable', 'string'], + 'admin_name' => ['required', 'string', 'max:255'], + 'admin_email' => ['required', 'email', 'max:255'], + 'admin_password' => ['required', 'string', 'min:8'], + ]); + + $appKey = 'base64:' . base64_encode(random_bytes(32)); + + $envLines = [ + 'APP_NAME="speedBB"', + 'APP_ENV=production', + 'APP_DEBUG=false', + 'APP_URL=' . $data['app_url'], + 'APP_KEY=' . $appKey, + '', + 'DB_CONNECTION=mysql', + 'DB_HOST=' . $data['db_host'], + 'DB_PORT=' . ($data['db_port'] ?: 3306), + 'DB_DATABASE=' . $data['db_database'], + 'DB_USERNAME=' . $data['db_username'], + 'DB_PASSWORD=' . ($data['db_password'] ?? ''), + '', + 'MAIL_MAILER=sendmail', + 'MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"', + 'MAIL_FROM_ADDRESS="hello@example.com"', + 'MAIL_FROM_NAME="speedBB"', + ]; + + $this->writeEnv(implode("\n", $envLines) . "\n"); + + config([ + 'app.key' => $appKey, + 'app.url' => $data['app_url'], + 'database.default' => 'mysql', + 'database.connections.mysql.host' => $data['db_host'], + 'database.connections.mysql.port' => (int) ($data['db_port'] ?: 3306), + 'database.connections.mysql.database' => $data['db_database'], + 'database.connections.mysql.username' => $data['db_username'], + 'database.connections.mysql.password' => $data['db_password'] ?? '', + 'mail.default' => 'sendmail', + 'mail.mailers.sendmail.path' => '/usr/sbin/sendmail -bs -i', + ]); + + DB::purge('mysql'); + + try { + DB::connection('mysql')->getPdo(); + } catch (\Throwable $e) { + $this->removeEnv(); + return view('installer', [ + 'appUrl' => $data['app_url'], + 'error' => 'Database connection failed: ' . $e->getMessage(), + 'old' => $data, + ]); + } + + $migrateExit = Artisan::call('migrate', ['--force' => true]); + if ($migrateExit !== 0) { + $this->removeEnv(); + return view('installer', [ + 'appUrl' => $data['app_url'], + 'error' => 'Migration failed. Please check your database credentials.', + 'old' => $data, + ]); + } + + $adminRole = Role::firstOrCreate(['name' => 'ROLE_ADMIN']); + $founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER']); + + $user = User::create([ + 'name' => $data['admin_name'], + 'name_canonical' => Str::lower(trim($data['admin_name'])), + 'email' => $data['admin_email'], + 'password' => Hash::make($data['admin_password']), + 'email_verified_at' => now(), + ]); + + $user->roles()->sync([$adminRole->id, $founderRole->id]); + + return view('installer-success'); + } + + private function envExists(): bool + { + return file_exists(base_path('.env')); + } + + private function writeEnv(string $contents): void + { + $path = base_path('.env'); + file_put_contents($path, $contents); + } + + private function removeEnv(): void + { + $path = base_path('.env'); + if (file_exists($path)) { + unlink($path); + } + } +} diff --git a/app/Http/Controllers/PortalController.php b/app/Http/Controllers/PortalController.php index 0260eb2..6503c96 100644 --- a/app/Http/Controllers/PortalController.php +++ b/app/Http/Controllers/PortalController.php @@ -33,8 +33,9 @@ class PortalController extends Controller ->withoutTrashed() ->withCount('posts') ->with([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), - 'latestPost.user', + 'user' => fn ($query) => $query->withCount('posts')->with(['rank', 'roles']), + 'latestPost.user.rank', + 'latestPost.user.roles', ]) ->latest('created_at') ->limit(12) @@ -62,7 +63,9 @@ class PortalController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), ] : null, ]); } @@ -82,6 +85,8 @@ class PortalController extends Controller 'last_post_at' => $lastPost?->created_at?->toIso8601String(), 'last_post_user_id' => $lastPost?->user_id, 'last_post_user_name' => $lastPost?->user?->name, + 'last_post_user_rank_color' => $lastPost?->user?->rank?->color, + 'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user), 'created_at' => $forum->created_at?->toIso8601String(), 'updated_at' => $forum->updated_at?->toIso8601String(), ]; @@ -109,12 +114,18 @@ class PortalController extends Controller 'user_rank_badge_url' => $thread->user?->rank?->badge_image_path ? Storage::url($thread->user->rank->badge_image_path) : null, + 'user_rank_color' => $thread->user?->rank?->color, + 'user_group_color' => $this->resolveGroupColor($thread->user), '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, + 'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color + ?? $thread->user?->rank?->color, + 'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user) + ?? $this->resolveGroupColor($thread->user), 'created_at' => $thread->created_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(), ]; @@ -133,7 +144,7 @@ class PortalController extends Controller ->whereNull('posts.deleted_at') ->whereNull('threads.deleted_at') ->orderByDesc('posts.created_at') - ->with('user') + ->with(['user.rank', 'user.roles']) ->get(); $byForum = []; @@ -146,4 +157,24 @@ class PortalController extends Controller return $byForum; } + + private function resolveGroupColor(?\App\Models\User $user): ?string + { + if (!$user) { + return null; + } + + $roles = $user->roles; + if (!$roles) { + return null; + } + + foreach ($roles->sortBy('name') as $role) { + if (!empty($role->color)) { + return $role->color; + } + } + + return null; + } } diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 4cfdddf..843b5cd 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -13,7 +13,9 @@ class PostController extends Controller public function index(Request $request): JsonResponse { $query = Post::query()->withoutTrashed()->with([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), + 'user' => fn ($query) => $query + ->withCount(['posts', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), ]); $threadParam = $request->query('thread'); @@ -49,7 +51,9 @@ class PostController extends Controller ]); $post->loadMissing([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), + 'user' => fn ($query) => $query + ->withCount(['posts', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), ]); return response()->json($this->serializePost($post), 201); @@ -95,14 +99,38 @@ class PostController extends Controller 'user_posts_count' => $post->user?->posts_count, 'user_created_at' => $post->user?->created_at?->toIso8601String(), 'user_location' => $post->user?->location, + 'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0, + 'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0, '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, + 'user_rank_color' => $post->user?->rank?->color, + 'user_group_color' => $this->resolveGroupColor($post->user), 'created_at' => $post->created_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(), ]; } + + private function resolveGroupColor(?\App\Models\User $user): ?string + { + if (!$user) { + return null; + } + + $roles = $user->roles; + if (!$roles) { + return null; + } + + foreach ($roles->sortBy('name') as $role) { + if (!empty($role->color)) { + return $role->color; + } + } + + return null; + } } diff --git a/app/Http/Controllers/PostThankController.php b/app/Http/Controllers/PostThankController.php new file mode 100644 index 0000000..c3363d6 --- /dev/null +++ b/app/Http/Controllers/PostThankController.php @@ -0,0 +1,122 @@ +user(); + if (!$user) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + $thank = PostThank::firstOrCreate([ + 'post_id' => $post->id, + 'user_id' => $user->id, + ]); + + return response()->json([ + 'id' => $thank->id, + 'post_id' => $post->id, + 'user_id' => $user->id, + ], 201); + } + + public function destroy(Request $request, Post $post): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + PostThank::where('post_id', $post->id) + ->where('user_id', $user->id) + ->delete(); + + return response()->json(null, 204); + } + + public function given(User $user): JsonResponse + { + $thanks = PostThank::query() + ->where('user_id', $user->id) + ->with(['post.thread', 'post.user.rank', 'post.user.roles']) + ->latest('created_at') + ->get() + ->map(fn (PostThank $thank) => $this->serializeGiven($thank)); + + return response()->json($thanks); + } + + public function received(User $user): JsonResponse + { + $thanks = PostThank::query() + ->whereHas('post', fn ($query) => $query->where('user_id', $user->id)) + ->with(['post.thread', 'user.rank', 'user.roles']) + ->latest('created_at') + ->get() + ->map(fn (PostThank $thank) => $this->serializeReceived($thank)); + + return response()->json($thanks); + } + + private function serializeGiven(PostThank $thank): array + { + return [ + 'id' => $thank->id, + 'post_id' => $thank->post_id, + 'thread_id' => $thank->post?->thread_id, + 'thread_title' => $thank->post?->thread?->title, + 'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null, + 'post_author_id' => $thank->post?->user_id, + 'post_author_name' => $thank->post?->user?->name, + 'post_author_rank_color' => $thank->post?->user?->rank?->color, + 'post_author_group_color' => $this->resolveGroupColor($thank->post?->user), + 'thanked_at' => $thank->created_at?->toIso8601String(), + ]; + } + + private function serializeReceived(PostThank $thank): array + { + return [ + 'id' => $thank->id, + 'post_id' => $thank->post_id, + 'thread_id' => $thank->post?->thread_id, + 'thread_title' => $thank->post?->thread?->title, + 'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null, + 'thanker_id' => $thank->user_id, + 'thanker_name' => $thank->user?->name, + 'thanker_rank_color' => $thank->user?->rank?->color, + 'thanker_group_color' => $this->resolveGroupColor($thank->user), + 'thanked_at' => $thank->created_at?->toIso8601String(), + ]; + } + + private function resolveGroupColor(?\App\Models\User $user): ?string + { + if (!$user) { + return null; + } + + $roles = $user->roles; + if (!$roles) { + return null; + } + + foreach ($roles->sortBy('name') as $role) { + if (!empty($role->color)) { + return $role->color; + } + } + + return null; + } +} diff --git a/app/Http/Controllers/RankController.php b/app/Http/Controllers/RankController.php index b69c573..7c24014 100644 --- a/app/Http/Controllers/RankController.php +++ b/app/Http/Controllers/RankController.php @@ -12,8 +12,8 @@ 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); + if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) { + return response()->json(data: ['message' => 'Forbidden'], status: 403); } return null; @@ -29,6 +29,7 @@ class RankController extends Controller 'name' => $rank->name, 'badge_type' => $rank->badge_type, 'badge_text' => $rank->badge_text, + 'color' => $rank->color, 'badge_image_url' => $rank->badge_image_path ? Storage::url($rank->badge_image_path) : null, @@ -45,19 +46,24 @@ class RankController extends Controller $data = $request->validate([ 'name' => ['required', 'string', 'max:100', 'unique:ranks,name'], - 'badge_type' => ['nullable', 'in:text,image'], + 'badge_type' => ['nullable', 'in:text,image,none'], 'badge_text' => ['nullable', 'string', 'max:40'], + 'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'], ]); $badgeType = $data['badge_type'] ?? 'text'; $badgeText = $badgeType === 'text' ? ($data['badge_text'] ?? $data['name']) : null; + if ($badgeType === 'none') { + $badgeText = null; + } $rank = Rank::create([ 'name' => $data['name'], 'badge_type' => $badgeType, 'badge_text' => $badgeText, + 'color' => $data['color'] ?? null, ]); return response()->json([ @@ -65,6 +71,7 @@ class RankController extends Controller 'name' => $rank->name, 'badge_type' => $rank->badge_type, 'badge_text' => $rank->badge_text, + 'color' => $rank->color, 'badge_image_url' => null, ], 201); } @@ -77,16 +84,21 @@ class RankController extends Controller $data = $request->validate([ 'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"], - 'badge_type' => ['nullable', 'in:text,image'], + 'badge_type' => ['nullable', 'in:text,image,none'], 'badge_text' => ['nullable', 'string', 'max:40'], + 'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'], ]); $badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text'; $badgeText = $badgeType === 'text' ? ($data['badge_text'] ?? $rank->badge_text ?? $data['name']) : null; + if ($badgeType === 'none') { + $badgeText = null; + } + $color = array_key_exists('color', $data) ? $data['color'] : $rank->color; - if ($badgeType === 'text' && $rank->badge_image_path) { + if ($badgeType !== 'image' && $rank->badge_image_path) { Storage::disk('public')->delete($rank->badge_image_path); $rank->badge_image_path = null; } @@ -95,6 +107,7 @@ class RankController extends Controller 'name' => $data['name'], 'badge_type' => $badgeType, 'badge_text' => $badgeText, + 'color' => $color, ]); return response()->json([ @@ -102,6 +115,7 @@ class RankController extends Controller 'name' => $rank->name, 'badge_type' => $rank->badge_type, 'badge_text' => $rank->badge_text, + 'color' => $rank->color, 'badge_image_url' => $rank->badge_image_path ? Storage::url($rank->badge_image_path) : null, diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php new file mode 100644 index 0000000..9f8e020 --- /dev/null +++ b/app/Http/Controllers/RoleController.php @@ -0,0 +1,141 @@ +user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + return null; + } + + public function index(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $roles = Role::query() + ->orderBy('name') + ->get() + ->map(fn (Role $role) => [ + 'id' => $role->id, + 'name' => $role->name, + 'color' => $role->color, + ]); + + return response()->json($roles); + } + + public function store(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100', 'unique:roles,name'], + 'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'], + ]); + + $normalizedName = $this->normalizeRoleName($data['name']); + if (Role::query()->where('name', $normalizedName)->exists()) { + return response()->json(['message' => 'Role already exists.'], 422); + } + + $role = Role::create([ + 'name' => $normalizedName, + 'color' => $data['color'] ?? null, + ]); + + return response()->json([ + 'id' => $role->id, + 'name' => $role->name, + 'color' => $role->color, + ], 201); + } + + public function update(Request $request, Role $role): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100', "unique:roles,name,{$role->id}"], + 'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'], + ]); + + $normalizedName = $this->normalizeRoleName($data['name']); + if (Role::query() + ->where('id', '!=', $role->id) + ->where('name', $normalizedName) + ->exists() + ) { + return response()->json(['message' => 'Role already exists.'], 422); + } + + if (in_array($role->name, self::CORE_ROLES, true) && $normalizedName !== $role->name) { + return response()->json(['message' => 'Core roles cannot be renamed.'], 422); + } + + $color = array_key_exists('color', $data) ? $data['color'] : $role->color; + + $role->update([ + 'name' => $normalizedName, + 'color' => $color, + ]); + + return response()->json([ + 'id' => $role->id, + 'name' => $role->name, + 'color' => $role->color, + ]); + } + + public function destroy(Request $request, Role $role): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + if (in_array($role->name, self::CORE_ROLES, true)) { + return response()->json(['message' => 'Core roles cannot be deleted.'], 422); + } + + if ($role->users()->exists()) { + return response()->json(['message' => 'Role is assigned to users.'], 422); + } + + $role->delete(); + + return response()->json(null, 204); + } + + private function normalizeRoleName(string $value): string + { + $raw = strtoupper(trim($value)); + $raw = preg_replace('/\s+/', '_', $raw); + $raw = preg_replace('/[^A-Z0-9_]/', '_', $raw); + $raw = preg_replace('/_+/', '_', $raw); + $raw = trim($raw, '_'); + if ($raw === '') { + return 'ROLE_'; + } + if (str_starts_with($raw, 'ROLE_')) { + return $raw; + } + return "ROLE_{$raw}"; + } +} diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index 762f68f..02197d5 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -6,6 +6,7 @@ use App\Models\Forum; use App\Models\Thread; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; class ThreadController extends Controller @@ -15,9 +16,13 @@ class ThreadController extends Controller $query = Thread::query() ->withoutTrashed() ->withCount('posts') + ->withMax('posts', 'created_at') ->with([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), - 'latestPost.user', + 'user' => fn ($query) => $query + ->withCount(['posts', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), + 'latestPost.user.rank', + 'latestPost.user.roles', ]); $forumParam = $request->query('forum'); @@ -29,7 +34,7 @@ class ThreadController extends Controller } $threads = $query - ->latest('created_at') + ->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)')) ->get() ->map(fn (Thread $thread) => $this->serializeThread($thread)); @@ -41,8 +46,11 @@ class ThreadController extends Controller $thread->increment('views_count'); $thread->refresh(); $thread->loadMissing([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), - 'latestPost.user', + 'user' => fn ($query) => $query + ->withCount(['posts', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), + 'latestPost.user.rank', + 'latestPost.user.roles', ])->loadCount('posts'); return response()->json($this->serializeThread($thread)); } @@ -70,8 +78,11 @@ class ThreadController extends Controller ]); $thread->loadMissing([ - 'user' => fn ($query) => $query->withCount('posts')->with('rank'), - 'latestPost.user', + 'user' => fn ($query) => $query + ->withCount(['posts', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), + 'latestPost.user.rank', + 'latestPost.user.roles', ])->loadCount('posts'); return response()->json($this->serializeThread($thread), 201); @@ -120,20 +131,48 @@ class ThreadController extends Controller 'user_posts_count' => $thread->user?->posts_count, 'user_created_at' => $thread->user?->created_at?->toIso8601String(), 'user_location' => $thread->user?->location, + 'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0, + 'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0, '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, + 'user_rank_color' => $thread->user?->rank?->color, + 'user_group_color' => $this->resolveGroupColor($thread->user), '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, + 'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color + ?? $thread->user?->rank?->color, + 'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user) + ?? $this->resolveGroupColor($thread->user), 'created_at' => $thread->created_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(), ]; } + + private function resolveGroupColor(?\App\Models\User $user): ?string + { + if (!$user) { + return null; + } + + $roles = $user->roles; + if (!$roles) { + return null; + } + + foreach ($roles->sortBy('name') as $role) { + if (!empty($role->color)) { + return $role->color; + } + } + + return null; + } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 8786871..b1d92d5 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Role; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -26,7 +27,9 @@ class UserController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), 'roles' => $user->roles->pluck('name')->values(), ]); @@ -50,7 +53,9 @@ class UserController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), 'roles' => $user->roles()->pluck('name')->values(), ]); } @@ -65,7 +70,9 @@ class UserController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), 'created_at' => $user->created_at?->toIso8601String(), ]); } @@ -101,7 +108,9 @@ class UserController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), 'roles' => $user->roles()->pluck('name')->values(), ]); } @@ -112,6 +121,9 @@ class UserController extends Controller if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) { return response()->json(['message' => 'Forbidden'], 403); } + if ($this->isFounder($user) && !$this->isFounder($actor)) { + return response()->json(['message' => 'Forbidden'], 403); + } $data = $request->validate([ 'rank_id' => ['nullable', 'exists:ranks,id'], @@ -127,7 +139,9 @@ class UserController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), ]); } @@ -137,6 +151,9 @@ class UserController extends Controller if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) { return response()->json(['message' => 'Forbidden'], 403); } + if ($this->isFounder($user) && !$this->isFounder($actor)) { + return response()->json(['message' => 'Forbidden'], 403); + } $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], @@ -148,8 +165,18 @@ class UserController extends Controller Rule::unique('users', 'email')->ignore($user->id), ], 'rank_id' => ['nullable', 'exists:ranks,id'], + 'roles' => ['nullable', 'array'], + 'roles.*' => ['string', 'exists:roles,name'], ]); + if (array_key_exists('roles', $data) && !$this->isFounder($actor)) { + $requested = collect($data['roles'] ?? []) + ->map(fn ($name) => $this->normalizeRoleName($name)); + if ($requested->contains('ROLE_FOUNDER')) { + return response()->json(['message' => 'Forbidden'], 403); + } + } + $nameCanonical = Str::lower(trim($data['name'])); $nameConflict = User::query() ->where('id', '!=', $user->id) @@ -171,6 +198,19 @@ class UserController extends Controller 'rank_id' => $data['rank_id'] ?? null, ])->save(); + if (array_key_exists('roles', $data)) { + $roleNames = collect($data['roles'] ?? []) + ->map(fn ($name) => $this->normalizeRoleName($name)) + ->unique() + ->values() + ->all(); + $roleIds = Role::query() + ->whereIn('name', $roleNames) + ->pluck('id') + ->all(); + $user->roles()->sync($roleIds); + } + $user->loadMissing('rank'); return response()->json([ @@ -181,7 +221,9 @@ class UserController extends Controller 'rank' => $user->rank ? [ 'id' => $user->rank->id, 'name' => $user->rank->name, + 'color' => $user->rank->color, ] : null, + 'group_color' => $this->resolveGroupColor($user), 'roles' => $user->roles()->pluck('name')->values(), ]); } @@ -194,4 +236,42 @@ class UserController extends Controller return Storage::url($user->avatar_path); } + + private function resolveGroupColor(User $user): ?string + { + $user->loadMissing('roles'); + $roles = $user->roles; + if (!$roles) { + return null; + } + + foreach ($roles->sortBy('name') as $role) { + if (!empty($role->color)) { + return $role->color; + } + } + + return null; + } + + private function normalizeRoleName(string $value): string + { + $raw = strtoupper(trim($value)); + $raw = preg_replace('/\s+/', '_', $raw); + $raw = preg_replace('/[^A-Z0-9_]/', '_', $raw); + $raw = preg_replace('/_+/', '_', $raw); + $raw = trim($raw, '_'); + if ($raw === '') { + return 'ROLE_'; + } + if (str_starts_with($raw, 'ROLE_')) { + return $raw; + } + return "ROLE_{$raw}"; + } + + private function isFounder(User $user): bool + { + return $user->roles()->where('name', 'ROLE_FOUNDER')->exists(); + } } diff --git a/app/Models/Post.php b/app/Models/Post.php index f9f34d5..1db2323 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -45,4 +46,9 @@ class Post extends Model { return $this->belongsTo(User::class); } + + public function thanks(): HasMany + { + return $this->hasMany(PostThank::class); + } } diff --git a/app/Models/PostThank.php b/app/Models/PostThank.php new file mode 100644 index 0000000..93401fd --- /dev/null +++ b/app/Models/PostThank.php @@ -0,0 +1,24 @@ +belongsTo(Post::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Rank.php b/app/Models/Rank.php index 579ce38..02206de 100644 --- a/app/Models/Rank.php +++ b/app/Models/Rank.php @@ -19,6 +19,7 @@ class Rank extends Model 'badge_type', 'badge_text', 'badge_image_path', + 'color', ]; public function users(): HasMany diff --git a/app/Models/Role.php b/app/Models/Role.php index af725ab..c7b8a3a 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -25,6 +25,7 @@ class Role extends Model { protected $fillable = [ 'name', + 'color', ]; public function users(): BelongsToMany diff --git a/app/Models/User.php b/app/Models/User.php index 77c4276..8646910 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\DatabaseNotification; use Illuminate\Notifications\DatabaseNotificationCollection; @@ -106,6 +107,16 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(Post::class); } + public function thanksGiven(): HasMany + { + return $this->hasMany(PostThank::class); + } + + public function thanksReceived(): HasManyThrough + { + return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id'); + } + public function rank() { return $this->belongsTo(Rank::class); diff --git a/database/migrations/2026_01_13_020000_create_post_thanks_table.php b/database/migrations/2026_01_13_020000_create_post_thanks_table.php new file mode 100644 index 0000000..7d00f1a --- /dev/null +++ b/database/migrations/2026_01_13_020000_create_post_thanks_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['post_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_thanks'); + } +}; diff --git a/database/migrations/2026_01_17_000000_add_color_to_ranks_table.php b/database/migrations/2026_01_17_000000_add_color_to_ranks_table.php new file mode 100644 index 0000000..9351479 --- /dev/null +++ b/database/migrations/2026_01_17_000000_add_color_to_ranks_table.php @@ -0,0 +1,28 @@ +string('color', 20)->nullable()->after('badge_image_path'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ranks', function (Blueprint $table) { + $table->dropColumn('color'); + }); + } +}; diff --git a/database/migrations/2026_01_18_000000_normalize_role_names.php b/database/migrations/2026_01_18_000000_normalize_role_names.php new file mode 100644 index 0000000..5b10404 --- /dev/null +++ b/database/migrations/2026_01_18_000000_normalize_role_names.php @@ -0,0 +1,54 @@ +select(['id', 'name']) + ->get(); + + foreach ($roles as $role) { + $name = (string) $role->name; + if (str_starts_with($name, 'ROLE_')) { + continue; + } + $raw = strtoupper(trim($name)); + $raw = preg_replace('/\s+/', '_', $raw); + $raw = preg_replace('/[^A-Z0-9_]/', '_', $raw); + $raw = preg_replace('/_+/', '_', $raw); + $raw = trim($raw, '_'); + if ($raw === '') { + continue; + } + $normalized = str_starts_with($raw, 'ROLE_') ? $raw : "ROLE_{$raw}"; + + $exists = DB::table('roles') + ->where('id', '!=', $role->id) + ->where('name', $normalized) + ->exists(); + + if ($exists) { + continue; + } + + DB::table('roles') + ->where('id', $role->id) + ->update(['name' => $normalized]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No safe reversal. + } +}; diff --git a/database/migrations/2026_01_18_010000_add_color_to_roles_table.php b/database/migrations/2026_01_18_010000_add_color_to_roles_table.php new file mode 100644 index 0000000..c169dd5 --- /dev/null +++ b/database/migrations/2026_01_18_010000_add_color_to_roles_table.php @@ -0,0 +1,28 @@ +string('color', 20)->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn('color'); + }); + } +}; diff --git a/public/index.php b/public/index.php index ee8f07e..ff1c28f 100644 --- a/public/index.php +++ b/public/index.php @@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) // Register the Composer autoloader... require __DIR__.'/../vendor/autoload.php'; +// Allow the installer to run without a .env file. +if (!file_exists(__DIR__.'/../.env')) { + $tempKey = 'base64:'.base64_encode(random_bytes(32)); + $_ENV['APP_KEY'] = $tempKey; + $_SERVER['APP_KEY'] = $tempKey; + $_ENV['DB_CONNECTION'] = 'sqlite'; + $_SERVER['DB_CONNECTION'] = 'sqlite'; + $_ENV['DB_DATABASE'] = ':memory:'; + $_SERVER['DB_DATABASE'] = ':memory:'; + $_ENV['SESSION_DRIVER'] = 'array'; + $_SERVER['SESSION_DRIVER'] = 'array'; + $_ENV['SESSION_DOMAIN'] = null; + $_SERVER['SESSION_DOMAIN'] = null; + $_ENV['SESSION_SECURE_COOKIE'] = false; + $_SERVER['SESSION_SECURE_COOKIE'] = false; +} + // Bootstrap Laravel and handle the request... /** @var Application $app */ $app = require_once __DIR__.'/../bootstrap/app.php'; diff --git a/resources/js/App.jsx b/resources/js/App.jsx index 45ff43d..51d0d01 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -15,496 +15,496 @@ import { useTranslation } from 'react-i18next' import { fetchSettings, fetchVersion, getForum, getThread } from './api/client' function PortalHeader({ - userMenu, - isAuthenticated, - forumName, - logoUrl, - showHeaderName, - canAccessAcp, - canAccessMcp, + userMenu, + isAuthenticated, + forumName, + logoUrl, + showHeaderName, + canAccessAcp, + canAccessMcp, }) { - const { t } = useTranslation() - const location = useLocation() - const [crumbs, setCrumbs] = useState([]) + const { t } = useTranslation() + const location = useLocation() + const [crumbs, setCrumbs] = useState([]) - useEffect(() => { - let active = true + useEffect(() => { + let active = true - const parseForumId = (parent) => { - if (!parent) return null - if (typeof parent === 'string') { - const parts = parent.split('/') - return parts[parts.length - 1] || null - } - if (typeof parent === 'object' && parent.id) { - return parent.id - } - return null - } + const parseForumId = (parent) => { + if (!parent) return null + if (typeof parent === 'string') { + const parts = parent.split('/') + return parts[parts.length - 1] || null + } + if (typeof parent === 'object' && parent.id) { + return parent.id + } + return null + } - const buildForumChain = async (forum) => { - const chain = [] - let cursor = forum + const buildForumChain = async (forum) => { + const chain = [] + let cursor = forum - while (cursor) { - chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` }) - const parentId = parseForumId(cursor.parent) - if (!parentId) break - cursor = await getForum(parentId) - } + while (cursor) { + chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` }) + const parentId = parseForumId(cursor.parent) + if (!parentId) break + cursor = await getForum(parentId) + } - return chain - } + return chain + } - const buildCrumbs = async () => { - const base = [ - { label: t('portal.portal'), to: '/' }, - { label: t('portal.board_index'), to: '/forums' }, - ] + const buildCrumbs = async () => { + const base = [ + { label: t('portal.portal'), to: '/' }, + { label: t('portal.board_index'), to: '/forums' }, + ] - if (location.pathname === '/') { - setCrumbs([{ ...base[0], current: true }, { ...base[1] }]) - return - } + if (location.pathname === '/') { + setCrumbs([{ ...base[0], current: true }, { ...base[1] }]) + return + } - if (location.pathname === '/forums') { - setCrumbs([{ ...base[0] }, { ...base[1], current: true }]) - return - } + if (location.pathname === '/forums') { + setCrumbs([{ ...base[0] }, { ...base[1], current: true }]) + return + } - if (location.pathname.startsWith('/forum/')) { - const forumId = location.pathname.split('/')[2] - if (forumId) { - const forum = await getForum(forumId) - const chain = await buildForumChain(forum) - if (!active) return - setCrumbs([...base, ...chain.map((crumb, idx) => ({ - ...crumb, - current: idx === chain.length - 1, - }))]) - return - } - } + if (location.pathname.startsWith('/forum/')) { + const forumId = location.pathname.split('/')[2] + if (forumId) { + const forum = await getForum(forumId) + const chain = await buildForumChain(forum) + if (!active) return + setCrumbs([...base, ...chain.map((crumb, idx) => ({ + ...crumb, + current: idx === chain.length - 1, + }))]) + return + } + } - if (location.pathname.startsWith('/thread/')) { - const threadId = location.pathname.split('/')[2] - if (threadId) { - const thread = await getThread(threadId) - const forumId = thread?.forum?.split('/').pop() - if (forumId) { - const forum = await getForum(forumId) - const chain = await buildForumChain(forum) - if (!active) return - const chainWithCurrent = chain.map((crumb, index) => ({ - ...crumb, - current: index === chain.length - 1, - })) - setCrumbs([...base, ...chainWithCurrent]) - return - } - } - } + if (location.pathname.startsWith('/thread/')) { + const threadId = location.pathname.split('/')[2] + if (threadId) { + const thread = await getThread(threadId) + const forumId = thread?.forum?.split('/').pop() + if (forumId) { + const forum = await getForum(forumId) + const chain = await buildForumChain(forum) + if (!active) return + const chainWithCurrent = chain.map((crumb, index) => ({ + ...crumb, + current: index === chain.length - 1, + })) + setCrumbs([...base, ...chainWithCurrent]) + return + } + } + } - if (location.pathname.startsWith('/acp')) { - setCrumbs([ - { ...base[0] }, - { ...base[1] }, - { label: t('portal.link_acp'), to: '/acp', current: true }, - ]) - return - } + 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('/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 - } + 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 }]) + } - buildCrumbs() + buildCrumbs() - return () => { - active = false - } - }, [location.pathname, t]) + return () => { + active = false + } + }, [location.pathname, t]) - return ( - -
-
- - {logoUrl && ( - {forumName - )} - {(showHeaderName || !logoUrl) && ( -
{forumName || '24unix.net'}
- )} - -
-
- - -