feat: add installer, ranks/groups enhancements, and founder protections

This commit is contained in:
Micha
2026-01-18 15:52:53 +01:00
parent 24c16ed0dd
commit 073c81012b
43 changed files with 6176 additions and 4039 deletions

2
.gitignore vendored
View File

@@ -21,6 +21,8 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/storage/app
/storage/framework
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/framework/views/*.php /storage/framework/views/*.php

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Forum; use App\Models\Forum;
use App\Models\Post; use App\Models\Post;
use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -14,31 +15,31 @@ class ForumController extends Controller
{ {
$query = Forum::query() $query = Forum::query()
->withoutTrashed() ->withoutTrashed()
->withCount(['threads', 'posts']) ->withCount(relations: ['threads', 'posts'])
->withSum('threads', 'views_count'); ->withSum(relation: 'threads', column: 'views_count');
$parentParam = $request->query('parent'); $parentParam = $request->query(key: 'parent');
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) { if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
if ($exists === false) { if ($exists === false) {
$query->whereNull('parent_id'); $query->whereNull(columns: 'parent_id');
} elseif ($exists === true) { } elseif ($exists === true) {
$query->whereNotNull('parent_id'); $query->whereNotNull(columns: 'parent_id');
} }
} elseif (is_string($parentParam)) { } elseif (is_string(value: $parentParam)) {
$parentId = $this->parseIriId($parentParam); $parentId = $this->parseIriId(value: $parentParam);
if ($parentId !== null) { if ($parentId !== null) {
$query->where('parent_id', $parentId); $query->where(column: 'parent_id', operator: $parentId);
} }
} }
if ($request->filled('type')) { if ($request->filled(key: 'type')) {
$query->where('type', $request->query('type')); $query->where(column: 'type', operator: $request->query(key: 'type'));
} }
$forums = $query $forums = $query
->orderBy('position') ->orderBy(column: 'position')
->orderBy('name') ->orderBy(column: 'name')
->get(); ->get();
$forumIds = $forums->pluck('id')->all(); $forumIds = $forums->pluck('id')->all();
@@ -216,6 +217,8 @@ class ForumController extends Controller
'last_post_at' => $lastPost?->created_at?->toIso8601String(), 'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id, 'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name, '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(), 'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_at?->toIso8601String(), 'updated_at' => $forum->updated_at?->toIso8601String(),
]; ];
@@ -234,7 +237,7 @@ class ForumController extends Controller
->whereNull('posts.deleted_at') ->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at') ->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at') ->orderByDesc('posts.created_at')
->with('user') ->with(['user.rank', 'user.roles'])
->get(); ->get();
$byForum = []; $byForum = [];
@@ -256,8 +259,28 @@ class ForumController extends Controller
->where('threads.forum_id', $forumId) ->where('threads.forum_id', $forumId)
->whereNull('posts.deleted_at') ->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at') ->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at') ->orderByDesc(column: 'posts.created_at')
->with('user') ->with(relations: ['user.rank', 'user.roles'])
->first(); ->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;
}
} }

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\View\View;
class InstallerController extends Controller
{
public function show(Request $request): View|RedirectResponse
{
if ($this->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);
}
}
}

View File

@@ -33,8 +33,9 @@ class PortalController extends Controller
->withoutTrashed() ->withoutTrashed()
->withCount('posts') ->withCount('posts')
->with([ ->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'), 'user' => fn ($query) => $query->withCount('posts')->with(['rank', 'roles']),
'latestPost.user', 'latestPost.user.rank',
'latestPost.user.roles',
]) ])
->latest('created_at') ->latest('created_at')
->limit(12) ->limit(12)
@@ -62,7 +63,9 @@ class PortalController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
] : null, ] : null,
]); ]);
} }
@@ -82,6 +85,8 @@ class PortalController extends Controller
'last_post_at' => $lastPost?->created_at?->toIso8601String(), 'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id, 'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name, '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(), 'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_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 'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path) ? Storage::url($thread->user->rank->badge_image_path)
: null, : null,
'user_rank_color' => $thread->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($thread->user),
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String() 'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(), ?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id, 'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id, 'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name 'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->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(), 'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(),
]; ];
@@ -133,7 +144,7 @@ class PortalController extends Controller
->whereNull('posts.deleted_at') ->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at') ->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at') ->orderByDesc('posts.created_at')
->with('user') ->with(['user.rank', 'user.roles'])
->get(); ->get();
$byForum = []; $byForum = [];
@@ -146,4 +157,24 @@ class PortalController extends Controller
return $byForum; 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;
}
} }

View File

@@ -13,7 +13,9 @@ class PostController extends Controller
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Post::query()->withoutTrashed()->with([ $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'); $threadParam = $request->query('thread');
@@ -49,7 +51,9 @@ class PostController extends Controller
]); ]);
$post->loadMissing([ $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); return response()->json($this->serializePost($post), 201);
@@ -95,14 +99,38 @@ class PostController extends Controller
'user_posts_count' => $post->user?->posts_count, 'user_posts_count' => $post->user?->posts_count,
'user_created_at' => $post->user?->created_at?->toIso8601String(), 'user_created_at' => $post->user?->created_at?->toIso8601String(),
'user_location' => $post->user?->location, 'user_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_name' => $post->user?->rank?->name,
'user_rank_badge_type' => $post->user?->rank?->badge_type, 'user_rank_badge_type' => $post->user?->rank?->badge_type,
'user_rank_badge_text' => $post->user?->rank?->badge_text, 'user_rank_badge_text' => $post->user?->rank?->badge_text,
'user_rank_badge_url' => $post->user?->rank?->badge_image_path 'user_rank_badge_url' => $post->user?->rank?->badge_image_path
? Storage::url($post->user->rank->badge_image_path) ? Storage::url($post->user->rank->badge_image_path)
: null, : null,
'user_rank_color' => $post->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($post->user),
'created_at' => $post->created_at?->toIso8601String(), 'created_at' => $post->created_at?->toIso8601String(),
'updated_at' => $post->updated_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;
}
} }

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostThank;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PostThankController extends Controller
{
public function store(Request $request, Post $post): JsonResponse
{
$user = $request->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;
}
}

View File

@@ -12,8 +12,8 @@ class RankController extends Controller
private function ensureAdmin(Request $request): ?JsonResponse private function ensureAdmin(Request $request): ?JsonResponse
{ {
$user = $request->user(); $user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403); return response()->json(data: ['message' => 'Forbidden'], status: 403);
} }
return null; return null;
@@ -29,6 +29,7 @@ class RankController extends Controller
'name' => $rank->name, 'name' => $rank->name,
'badge_type' => $rank->badge_type, 'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text, 'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => $rank->badge_image_path 'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path) ? Storage::url($rank->badge_image_path)
: null, : null,
@@ -45,19 +46,24 @@ class RankController extends Controller
$data = $request->validate([ $data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'], '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'], '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'; $badgeType = $data['badge_type'] ?? 'text';
$badgeText = $badgeType === 'text' $badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $data['name']) ? ($data['badge_text'] ?? $data['name'])
: null; : null;
if ($badgeType === 'none') {
$badgeText = null;
}
$rank = Rank::create([ $rank = Rank::create([
'name' => $data['name'], 'name' => $data['name'],
'badge_type' => $badgeType, 'badge_type' => $badgeType,
'badge_text' => $badgeText, 'badge_text' => $badgeText,
'color' => $data['color'] ?? null,
]); ]);
return response()->json([ return response()->json([
@@ -65,6 +71,7 @@ class RankController extends Controller
'name' => $rank->name, 'name' => $rank->name,
'badge_type' => $rank->badge_type, 'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text, 'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => null, 'badge_image_url' => null,
], 201); ], 201);
} }
@@ -77,16 +84,21 @@ class RankController extends Controller
$data = $request->validate([ $data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"], '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'], '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'; $badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
$badgeText = $badgeType === 'text' $badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name']) ? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
: null; : 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); Storage::disk('public')->delete($rank->badge_image_path);
$rank->badge_image_path = null; $rank->badge_image_path = null;
} }
@@ -95,6 +107,7 @@ class RankController extends Controller
'name' => $data['name'], 'name' => $data['name'],
'badge_type' => $badgeType, 'badge_type' => $badgeType,
'badge_text' => $badgeText, 'badge_text' => $badgeText,
'color' => $color,
]); ]);
return response()->json([ return response()->json([
@@ -102,6 +115,7 @@ class RankController extends Controller
'name' => $rank->name, 'name' => $rank->name,
'badge_type' => $rank->badge_type, 'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text, 'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => $rank->badge_image_path 'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path) ? Storage::url($rank->badge_image_path)
: null, : null,

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use App\Models\Role;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RoleController extends Controller
{
private const CORE_ROLES = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_FOUNDER'];
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
{
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}";
}
}

View File

@@ -6,6 +6,7 @@ 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\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class ThreadController extends Controller class ThreadController extends Controller
@@ -15,9 +16,13 @@ class ThreadController extends Controller
$query = Thread::query() $query = Thread::query()
->withoutTrashed() ->withoutTrashed()
->withCount('posts') ->withCount('posts')
->withMax('posts', 'created_at')
->with([ ->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'), 'user' => fn ($query) => $query
'latestPost.user', ->withCount(['posts', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
]); ]);
$forumParam = $request->query('forum'); $forumParam = $request->query('forum');
@@ -29,7 +34,7 @@ class ThreadController extends Controller
} }
$threads = $query $threads = $query
->latest('created_at') ->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
->get() ->get()
->map(fn (Thread $thread) => $this->serializeThread($thread)); ->map(fn (Thread $thread) => $this->serializeThread($thread));
@@ -41,8 +46,11 @@ class ThreadController extends Controller
$thread->increment('views_count'); $thread->increment('views_count');
$thread->refresh(); $thread->refresh();
$thread->loadMissing([ $thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'), 'user' => fn ($query) => $query
'latestPost.user', ->withCount(['posts', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts'); ])->loadCount('posts');
return response()->json($this->serializeThread($thread)); return response()->json($this->serializeThread($thread));
} }
@@ -70,8 +78,11 @@ class ThreadController extends Controller
]); ]);
$thread->loadMissing([ $thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'), 'user' => fn ($query) => $query
'latestPost.user', ->withCount(['posts', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts'); ])->loadCount('posts');
return response()->json($this->serializeThread($thread), 201); return response()->json($this->serializeThread($thread), 201);
@@ -120,20 +131,48 @@ class ThreadController extends Controller
'user_posts_count' => $thread->user?->posts_count, 'user_posts_count' => $thread->user?->posts_count,
'user_created_at' => $thread->user?->created_at?->toIso8601String(), 'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_location' => $thread->user?->location, 'user_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_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type, 'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text, 'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path 'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path) ? Storage::url($thread->user->rank->badge_image_path)
: null, : null,
'user_rank_color' => $thread->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($thread->user),
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String() 'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(), ?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id, 'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id, 'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name 'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->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(), 'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_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;
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -26,7 +27,9 @@ class UserController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles->pluck('name')->values(), 'roles' => $user->roles->pluck('name')->values(),
]); ]);
@@ -50,7 +53,9 @@ class UserController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(), 'roles' => $user->roles()->pluck('name')->values(),
]); ]);
} }
@@ -65,7 +70,9 @@ class UserController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
'created_at' => $user->created_at?->toIso8601String(), 'created_at' => $user->created_at?->toIso8601String(),
]); ]);
} }
@@ -101,7 +108,9 @@ class UserController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(), 'roles' => $user->roles()->pluck('name')->values(),
]); ]);
} }
@@ -112,6 +121,9 @@ class UserController extends Controller
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) { if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403); return response()->json(['message' => 'Forbidden'], 403);
} }
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([ $data = $request->validate([
'rank_id' => ['nullable', 'exists:ranks,id'], 'rank_id' => ['nullable', 'exists:ranks,id'],
@@ -127,7 +139,9 @@ class UserController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
]); ]);
} }
@@ -137,6 +151,9 @@ class UserController extends Controller
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) { if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403); return response()->json(['message' => 'Forbidden'], 403);
} }
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([ $data = $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
@@ -148,8 +165,18 @@ class UserController extends Controller
Rule::unique('users', 'email')->ignore($user->id), Rule::unique('users', 'email')->ignore($user->id),
], ],
'rank_id' => ['nullable', 'exists:ranks,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'])); $nameCanonical = Str::lower(trim($data['name']));
$nameConflict = User::query() $nameConflict = User::query()
->where('id', '!=', $user->id) ->where('id', '!=', $user->id)
@@ -171,6 +198,19 @@ class UserController extends Controller
'rank_id' => $data['rank_id'] ?? null, 'rank_id' => $data['rank_id'] ?? null,
])->save(); ])->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'); $user->loadMissing('rank');
return response()->json([ return response()->json([
@@ -181,7 +221,9 @@ class UserController extends Controller
'rank' => $user->rank ? [ 'rank' => $user->rank ? [
'id' => $user->rank->id, 'id' => $user->rank->id,
'name' => $user->rank->name, 'name' => $user->rank->name,
'color' => $user->rank->color,
] : null, ] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(), 'roles' => $user->roles()->pluck('name')->values(),
]); ]);
} }
@@ -194,4 +236,42 @@ class UserController extends Controller
return Storage::url($user->avatar_path); 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();
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Models; 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\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -45,4 +46,9 @@ class Post extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function thanks(): HasMany
{
return $this->hasMany(PostThank::class);
}
} }

24
app/Models/PostThank.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PostThank extends Model
{
protected $fillable = [
'post_id',
'user_id',
];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -19,6 +19,7 @@ class Rank extends Model
'badge_type', 'badge_type',
'badge_text', 'badge_text',
'badge_image_path', 'badge_image_path',
'color',
]; ];
public function users(): HasMany public function users(): HasMany

View File

@@ -25,6 +25,7 @@ class Role extends Model
{ {
protected $fillable = [ protected $fillable = [
'name', 'name',
'color',
]; ];
public function users(): BelongsToMany public function users(): BelongsToMany

View File

@@ -8,6 +8,7 @@ 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\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification; use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection; use Illuminate\Notifications\DatabaseNotificationCollection;
@@ -106,6 +107,16 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Post::class); 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() public function rank()
{ {
return $this->belongsTo(Rank::class); return $this->belongsTo(Rank::class);

View File

@@ -0,0 +1,25 @@
<?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::create('post_thanks', function (Blueprint $table) {
$table->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');
}
};

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('ranks', function (Blueprint $table) {
$table->string('color', 20)->nullable()->after('badge_image_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$roles = DB::table('roles')
->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.
}
};

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('roles', function (Blueprint $table) {
$table->string('color', 20)->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View File

@@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php'))
// Register the Composer autoloader... // Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php'; 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... // Bootstrap Laravel and handle the request...
/** @var Application $app */ /** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php'; $app = require_once __DIR__.'/../bootstrap/app.php';

View File

@@ -15,496 +15,496 @@ import { useTranslation } from 'react-i18next'
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client' import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({ function PortalHeader({
userMenu, userMenu,
isAuthenticated, isAuthenticated,
forumName, forumName,
logoUrl, logoUrl,
showHeaderName, showHeaderName,
canAccessAcp, canAccessAcp,
canAccessMcp, canAccessMcp,
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const location = useLocation() const location = useLocation()
const [crumbs, setCrumbs] = useState([]) const [crumbs, setCrumbs] = useState([])
useEffect(() => { useEffect(() => {
let active = true let active = true
const parseForumId = (parent) => { const parseForumId = (parent) => {
if (!parent) return null if (!parent) return null
if (typeof parent === 'string') { if (typeof parent === 'string') {
const parts = parent.split('/') const parts = parent.split('/')
return parts[parts.length - 1] || null return parts[parts.length - 1] || null
} }
if (typeof parent === 'object' && parent.id) { if (typeof parent === 'object' && parent.id) {
return parent.id return parent.id
} }
return null return null
} }
const buildForumChain = async (forum) => { const buildForumChain = async (forum) => {
const chain = [] const chain = []
let cursor = forum let cursor = forum
while (cursor) { while (cursor) {
chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` }) chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` })
const parentId = parseForumId(cursor.parent) const parentId = parseForumId(cursor.parent)
if (!parentId) break if (!parentId) break
cursor = await getForum(parentId) cursor = await getForum(parentId)
} }
return chain return chain
} }
const buildCrumbs = async () => { const buildCrumbs = async () => {
const base = [ const base = [
{ label: t('portal.portal'), to: '/' }, { label: t('portal.portal'), to: '/' },
{ label: t('portal.board_index'), to: '/forums' }, { label: t('portal.board_index'), to: '/forums' },
] ]
if (location.pathname === '/') { if (location.pathname === '/') {
setCrumbs([{ ...base[0], current: true }, { ...base[1] }]) setCrumbs([{ ...base[0], current: true }, { ...base[1] }])
return return
} }
if (location.pathname === '/forums') { if (location.pathname === '/forums') {
setCrumbs([{ ...base[0] }, { ...base[1], current: true }]) setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
return return
} }
if (location.pathname.startsWith('/forum/')) { if (location.pathname.startsWith('/forum/')) {
const forumId = location.pathname.split('/')[2] const forumId = location.pathname.split('/')[2]
if (forumId) { if (forumId) {
const forum = await getForum(forumId) const forum = await getForum(forumId)
const chain = await buildForumChain(forum) const chain = await buildForumChain(forum)
if (!active) return if (!active) return
setCrumbs([...base, ...chain.map((crumb, idx) => ({ setCrumbs([...base, ...chain.map((crumb, idx) => ({
...crumb, ...crumb,
current: idx === chain.length - 1, current: idx === chain.length - 1,
}))]) }))])
return return
} }
} }
if (location.pathname.startsWith('/thread/')) { if (location.pathname.startsWith('/thread/')) {
const threadId = location.pathname.split('/')[2] const threadId = location.pathname.split('/')[2]
if (threadId) { if (threadId) {
const thread = await getThread(threadId) const thread = await getThread(threadId)
const forumId = thread?.forum?.split('/').pop() const forumId = thread?.forum?.split('/').pop()
if (forumId) { if (forumId) {
const forum = await getForum(forumId) const forum = await getForum(forumId)
const chain = await buildForumChain(forum) const chain = await buildForumChain(forum)
if (!active) return if (!active) return
const chainWithCurrent = chain.map((crumb, index) => ({ const chainWithCurrent = chain.map((crumb, index) => ({
...crumb, ...crumb,
current: index === chain.length - 1, current: index === chain.length - 1,
})) }))
setCrumbs([...base, ...chainWithCurrent]) setCrumbs([...base, ...chainWithCurrent])
return return
} }
} }
} }
if (location.pathname.startsWith('/acp')) { if (location.pathname.startsWith('/acp')) {
setCrumbs([ setCrumbs([
{ ...base[0] }, { ...base[0] },
{ ...base[1] }, { ...base[1] },
{ label: t('portal.link_acp'), to: '/acp', current: true }, { label: t('portal.link_acp'), to: '/acp', current: true },
]) ])
return return
} }
if (location.pathname.startsWith('/ucp')) { if (location.pathname.startsWith('/ucp')) {
setCrumbs([ setCrumbs([
{ ...base[0] }, { ...base[0] },
{ ...base[1] }, { ...base[1] },
{ label: t('portal.user_control_panel'), to: '/ucp', current: true }, { label: t('portal.user_control_panel'), to: '/ucp', current: true },
]) ])
return return
} }
if (location.pathname.startsWith('/profile/')) { if (location.pathname.startsWith('/profile/')) {
setCrumbs([ setCrumbs([
{ ...base[0] }, { ...base[0] },
{ ...base[1] }, { ...base[1] },
{ label: t('portal.user_profile'), to: location.pathname, current: true }, { label: t('portal.user_profile'), to: location.pathname, current: true },
]) ])
return return
} }
setCrumbs([{ ...base[0] }, { ...base[1], current: true }]) setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
} }
buildCrumbs() buildCrumbs()
return () => { return () => {
active = false active = false
} }
}, [location.pathname, t]) }, [location.pathname, t])
return ( return (
<Container fluid 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'}> <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> </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 />
<span className="bb-portal-search-icon"> <span className="bb-portal-search-icon">
<i className="bi bi-search" aria-hidden="true" /> <i className="bi bi-search" aria-hidden="true" />
</span> </span>
</div> </div>
</div> </div>
<div className="bb-portal-bars"> <div className="bb-portal-bars">
<div className="bb-portal-bar bb-portal-bar--top"> <div className="bb-portal-bar bb-portal-bar--top">
<div className="bb-portal-bar-left"> <div className="bb-portal-bar-left">
<span className="bb-portal-bar-title"> <span className="bb-portal-bar-title">
<i className="bi bi-list" aria-hidden="true" /> {t('portal.quick_links')} <i className="bi bi-list" aria-hidden="true" /> {t('portal.quick_links')}
</span> </span>
<div className="bb-portal-bar-links"> <div className="bb-portal-bar-links">
<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 && ( {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 && ( {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
className={`bb-portal-user-links${isAuthenticated ? '' : ' bb-portal-user-links--guest'}`} className={`bb-portal-user-links${isAuthenticated ? '' : ' bb-portal-user-links--guest'}`}
> >
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<span> <span>
<i className="bi bi-bell-fill" aria-hidden="true" /> {t('portal.notifications')} <i className="bi bi-bell-fill" aria-hidden="true" /> {t('portal.notifications')}
</span> </span>
<span> <span>
<i className="bi bi-envelope-fill" aria-hidden="true" /> {t('portal.messages')} <i className="bi bi-envelope-fill" aria-hidden="true" /> {t('portal.messages')}
</span> </span>
{userMenu} {userMenu}
</> </>
) : ( ) : (
<> <>
<Link to="/register" className="bb-portal-user-link"> <Link to="/register" className="bb-portal-user-link">
<i className="bi bi-pencil-square" aria-hidden="true" /> {t('nav.register')} <i className="bi bi-pencil-square" aria-hidden="true" /> {t('nav.register')}
</Link> </Link>
<Link to="/login" className="bb-portal-user-link"> <Link to="/login" className="bb-portal-user-link">
<i className="bi bi-power" aria-hidden="true" /> {t('nav.login')} <i className="bi bi-power" aria-hidden="true" /> {t('nav.login')}
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
<div className="bb-portal-bar bb-portal-bar--bottom"> <div className="bb-portal-bar bb-portal-bar--bottom">
<div className="bb-portal-breadcrumb"> <div className="bb-portal-breadcrumb">
{crumbs.map((crumb, index) => ( {crumbs.map((crumb, index) => (
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb"> <span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
{index > 0 && <span className="bb-portal-sep"></span>} {index > 0 && <span className="bb-portal-sep"></span>}
{crumb.current ? ( {crumb.current ? (
<span className="bb-portal-current"> <Link to={crumb.to} className="bb-portal-current bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />} {index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />} {index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label} {crumb.label}
</span> </Link>
) : ( ) : (
<Link to={crumb.to} className="bb-portal-link"> <Link to={crumb.to} className="bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />} {index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />} {index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label} {crumb.label}
</Link> </Link>
)} )}
</span> </span>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</Container> </Container>
) )
} }
function AppShell() { function AppShell() {
const { t } = useTranslation() const { t } = useTranslation()
const { token, email, userId, logout, isAdmin, isModerator } = 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')
const [accentOverride, setAccentOverride] = useState( const [accentOverride, setAccentOverride] = useState(
() => localStorage.getItem('speedbb_accent') || '' () => localStorage.getItem('speedbb_accent') || ''
) )
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
forumName: '', forumName: '',
defaultTheme: 'auto', defaultTheme: 'auto',
accentDark: '', accentDark: '',
accentLight: '', accentLight: '',
logoDark: '', logoDark: '',
logoLight: '', logoLight: '',
showHeaderName: true, showHeaderName: true,
faviconIco: '', faviconIco: '',
favicon16: '', favicon16: '',
favicon32: '', favicon32: '',
favicon48: '', favicon48: '',
favicon64: '', favicon64: '',
favicon128: '', favicon128: '',
favicon256: '', favicon256: '',
}) })
useEffect(() => { useEffect(() => {
fetchVersion() fetchVersion()
.then((data) => setVersionInfo(data)) .then((data) => setVersionInfo(data))
.catch(() => setVersionInfo(null)) .catch(() => setVersionInfo(null))
}, []) }, [])
useEffect(() => { useEffect(() => {
let active = true let active = true
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const allSettings = await fetchSettings() const allSettings = await fetchSettings()
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value])) const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
if (!active) return if (!active) return
const next = { const next = {
forumName: settingsMap.get('forum_name') || '', forumName: settingsMap.get('forum_name') || '',
defaultTheme: settingsMap.get('default_theme') || 'auto', defaultTheme: settingsMap.get('default_theme') || 'auto',
accentDark: settingsMap.get('accent_color_dark') || '', accentDark: settingsMap.get('accent_color_dark') || '',
accentLight: settingsMap.get('accent_color_light') || '', accentLight: settingsMap.get('accent_color_light') || '',
logoDark: settingsMap.get('logo_dark') || '', logoDark: settingsMap.get('logo_dark') || '',
logoLight: settingsMap.get('logo_light') || '', logoLight: settingsMap.get('logo_light') || '',
showHeaderName: settingsMap.get('show_header_name') !== 'false', showHeaderName: settingsMap.get('show_header_name') !== 'false',
faviconIco: settingsMap.get('favicon_ico') || '', faviconIco: settingsMap.get('favicon_ico') || '',
favicon16: settingsMap.get('favicon_16') || '', favicon16: settingsMap.get('favicon_16') || '',
favicon32: settingsMap.get('favicon_32') || '', favicon32: settingsMap.get('favicon_32') || '',
favicon48: settingsMap.get('favicon_48') || '', favicon48: settingsMap.get('favicon_48') || '',
favicon64: settingsMap.get('favicon_64') || '', favicon64: settingsMap.get('favicon_64') || '',
favicon128: settingsMap.get('favicon_128') || '', favicon128: settingsMap.get('favicon_128') || '',
favicon256: settingsMap.get('favicon_256') || '', favicon256: settingsMap.get('favicon_256') || '',
} }
setSettings(next) setSettings(next)
} catch { } catch {
// keep defaults // keep defaults
} }
} }
loadSettings() loadSettings()
return () => { return () => {
active = false active = false
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
const stored = token ? localStorage.getItem('speedbb_theme') : null const stored = token ? localStorage.getItem('speedbb_theme') : null
const nextTheme = stored || settings.defaultTheme || 'auto' const nextTheme = stored || settings.defaultTheme || 'auto'
setTheme(nextTheme) setTheme(nextTheme)
}, [token, settings.defaultTheme]) }, [token, settings.defaultTheme])
useEffect(() => { useEffect(() => {
const handleSettingsUpdate = (event) => { const handleSettingsUpdate = (event) => {
const next = event.detail const next = event.detail
if (!next) return if (!next) return
setSettings((prev) => ({ ...prev, ...next })) setSettings((prev) => ({ ...prev, ...next }))
} }
window.addEventListener('speedbb-settings-updated', handleSettingsUpdate) window.addEventListener('speedbb-settings-updated', handleSettingsUpdate)
return () => { return () => {
window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate) window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate)
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
if (accentOverride) { if (accentOverride) {
localStorage.setItem('speedbb_accent', accentOverride) localStorage.setItem('speedbb_accent', accentOverride)
} else { } else {
localStorage.removeItem('speedbb_accent') localStorage.removeItem('speedbb_accent')
} }
}, [accentOverride]) }, [accentOverride])
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)') const media = window.matchMedia('(prefers-color-scheme: dark)')
const applyTheme = (mode) => { const applyTheme = (mode) => {
if (mode === 'auto') { if (mode === 'auto') {
const next = media.matches ? 'dark' : 'light' const next = media.matches ? 'dark' : 'light'
root.setAttribute('data-bs-theme', next) root.setAttribute('data-bs-theme', next)
setResolvedTheme(next) setResolvedTheme(next)
} else { } else {
root.setAttribute('data-bs-theme', mode) root.setAttribute('data-bs-theme', mode)
setResolvedTheme(mode) setResolvedTheme(mode)
} }
} }
applyTheme(theme) applyTheme(theme)
const handleChange = () => { const handleChange = () => {
if (theme === 'auto') { if (theme === 'auto') {
applyTheme('auto') applyTheme('auto')
} }
} }
media.addEventListener('change', handleChange) media.addEventListener('change', handleChange)
return () => { return () => {
media.removeEventListener('change', handleChange) media.removeEventListener('change', handleChange)
} }
}, [theme]) }, [theme])
useEffect(() => { useEffect(() => {
const accent = const accent =
accentOverride || accentOverride ||
(resolvedTheme === 'dark' ? settings.accentDark : settings.accentLight) || (resolvedTheme === 'dark' ? settings.accentDark : settings.accentLight) ||
settings.accentDark || settings.accentDark ||
settings.accentLight settings.accentLight
if (accent) { if (accent) {
document.documentElement.style.setProperty('--bb-accent', accent) document.documentElement.style.setProperty('--bb-accent', accent)
} }
}, [accentOverride, resolvedTheme, settings]) }, [accentOverride, resolvedTheme, settings])
useEffect(() => { useEffect(() => {
if (settings.forumName) { if (settings.forumName) {
document.title = settings.forumName document.title = settings.forumName
} }
}, [settings.forumName]) }, [settings.forumName])
useEffect(() => { useEffect(() => {
const upsertIcon = (id, rel, href, sizes, type) => { const upsertIcon = (id, rel, href, sizes, type) => {
if (!href) { if (!href) {
const existing = document.getElementById(id) const existing = document.getElementById(id)
if (existing) { if (existing) {
existing.remove() existing.remove()
} }
return return
} }
let link = document.getElementById(id) let link = document.getElementById(id)
if (!link) { if (!link) {
link = document.createElement('link') link = document.createElement('link')
link.id = id link.id = id
document.head.appendChild(link) document.head.appendChild(link)
} }
link.setAttribute('rel', rel) link.setAttribute('rel', rel)
link.setAttribute('href', href) link.setAttribute('href', href)
if (sizes) { if (sizes) {
link.setAttribute('sizes', sizes) link.setAttribute('sizes', sizes)
} else { } else {
link.removeAttribute('sizes') link.removeAttribute('sizes')
} }
if (type) { if (type) {
link.setAttribute('type', type) link.setAttribute('type', type)
} else { } else {
link.removeAttribute('type') link.removeAttribute('type')
} }
} }
upsertIcon('favicon-ico', 'icon', settings.faviconIco, null, 'image/x-icon') upsertIcon('favicon-ico', 'icon', settings.faviconIco, null, 'image/x-icon')
upsertIcon('favicon-16', 'icon', settings.favicon16, '16x16', 'image/png') upsertIcon('favicon-16', 'icon', settings.favicon16, '16x16', 'image/png')
upsertIcon('favicon-32', 'icon', settings.favicon32, '32x32', 'image/png') upsertIcon('favicon-32', 'icon', settings.favicon32, '32x32', 'image/png')
upsertIcon('favicon-48', 'icon', settings.favicon48, '48x48', 'image/png') upsertIcon('favicon-48', 'icon', settings.favicon48, '48x48', 'image/png')
upsertIcon('favicon-64', 'icon', settings.favicon64, '64x64', 'image/png') upsertIcon('favicon-64', 'icon', settings.favicon64, '64x64', 'image/png')
upsertIcon('favicon-128', 'icon', settings.favicon128, '128x128', 'image/png') upsertIcon('favicon-128', 'icon', settings.favicon128, '128x128', 'image/png')
upsertIcon('favicon-256', 'icon', settings.favicon256, '256x256', 'image/png') upsertIcon('favicon-256', 'icon', settings.favicon256, '256x256', 'image/png')
}, [ }, [
settings.faviconIco, settings.faviconIco,
settings.favicon16, settings.favicon16,
settings.favicon32, settings.favicon32,
settings.favicon48, settings.favicon48,
settings.favicon64, settings.favicon64,
settings.favicon128, settings.favicon128,
settings.favicon256, settings.favicon256,
]) ])
return ( return (
<div className="bb-shell"> <div className="bb-shell" id="top">
<PortalHeader <PortalHeader
isAuthenticated={!!token} isAuthenticated={!!token}
forumName={settings.forumName} forumName={settings.forumName}
logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight} logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight}
showHeaderName={settings.showHeaderName} showHeaderName={settings.showHeaderName}
userMenu={ userMenu={
token ? ( token ? (
<NavDropdown <NavDropdown
title={ title={
<span className="bb-user-menu"> <span className="bb-user-menu">
<span className="bb-user-menu__name">{email}</span> <span className="bb-user-menu__name">{email}</span>
<i className="bi bi-caret-down-fill" aria-hidden="true" /> <i className="bi bi-caret-down-fill" aria-hidden="true" />
</span> </span>
} }
align="end" align="end"
className="bb-user-menu__dropdown" className="bb-user-menu__dropdown"
> >
<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={`/profile/${userId ?? ''}`}> <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 />
<NavDropdown.Item onClick={logout}> <NavDropdown.Item onClick={logout}>
<i className="bi bi-power" aria-hidden="true" /> {t('portal.user_logout')} <i className="bi bi-power" aria-hidden="true" /> {t('portal.user_logout')}
</NavDropdown.Item> </NavDropdown.Item>
</NavDropdown> </NavDropdown>
) : null ) : null
} }
canAccessAcp={isAdmin} canAccessAcp={isAdmin}
canAccessMcp={isModerator} canAccessMcp={isModerator}
/> />
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} /> <Route path="/forums" element={<BoardIndex />} />
<Route path="/forum/:id" element={<ForumView />} /> <Route path="/forum/:id" element={<ForumView />} />
<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="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} /> <Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route <Route
path="/ucp" path="/ucp"
element={ element={
<Ucp <Ucp
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
accentOverride={accentOverride} accentOverride={accentOverride}
setAccentOverride={setAccentOverride} setAccentOverride={setAccentOverride}
/> />
} }
/> />
</Routes> </Routes>
<footer className="bb-footer"> <footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3"> <div className="ms-3 d-flex align-items-center gap-3">
<span>{t('footer.copy')}</span> <span>{t('footer.copy')}</span>
{versionInfo?.version && ( {versionInfo?.version && (
<span className="bb-version"> <span className="bb-version">
<span className="bb-version-label">Version:</span>{' '} <span className="bb-version-label">Version:</span>{' '}
<span className="bb-version-value">{versionInfo.version}</span>{' '} <span className="bb-version-value">{versionInfo.version}</span>{' '}
<span className="bb-version-label">(build:</span>{' '} <span className="bb-version-label">(build:</span>{' '}
<span className="bb-version-value">{versionInfo.build}</span> <span className="bb-version-value">{versionInfo.build}</span>
<span className="bb-version-label">)</span> <span className="bb-version-label">)</span>
</span> </span>
)} )}
</div> </div>
</footer> </footer>
</div> </div>
) )
} }
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<AppShell /> <AppShell />
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
) )
} }

View File

@@ -1,315 +1,347 @@
const API_BASE = '/api' const API_BASE = '/api'
async function parseResponse(response) { async function parseResponse(response) {
if (response.status === 204) { if (response.status === 204) {
return null return null
} }
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
const message = data?.message || data?.['hydra:description'] || response.statusText const message = data?.message || data?.['hydra:description'] || response.statusText
throw new Error(message) throw new Error(message)
} }
return data return data
} }
export async function apiFetch(path, options = {}) { export async function apiFetch(path, options = {}) {
const token = localStorage.getItem('speedbb_token') const token = localStorage.getItem('speedbb_token')
const headers = { const headers = {
Accept: 'application/json', Accept: 'application/json',
...(options.headers || {}), ...(options.headers || {}),
}
if (!(options.body instanceof FormData)) {
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
} }
} if (!(options.body instanceof FormData)) {
if (token) { if (!headers['Content-Type']) {
headers.Authorization = `Bearer ${token}` headers['Content-Type'] = 'application/json'
} }
const response = await fetch(`${API_BASE}${path}`, { }
...options, if (token) {
headers, headers.Authorization = `Bearer ${token}`
}) }
if (response.status === 401) { const response = await fetch(`${API_BASE}${path}`, {
localStorage.removeItem('speedbb_token') ...options,
localStorage.removeItem('speedbb_email') headers,
localStorage.removeItem('speedbb_user_id') })
localStorage.removeItem('speedbb_roles') if (response.status === 401) {
window.dispatchEvent(new Event('speedbb-unauthorized')) localStorage.removeItem('speedbb_token')
} localStorage.removeItem('speedbb_email')
return parseResponse(response) localStorage.removeItem('speedbb_user_id')
localStorage.removeItem('speedbb_roles')
window.dispatchEvent(new Event('speedbb-unauthorized'))
}
return parseResponse(response)
} }
export async function getCollection(path) { export async function getCollection(path) {
const data = await apiFetch(path) const data = await apiFetch(path)
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data return data
} }
return data?.['hydra:member'] || [] return data?.['hydra:member'] || []
} }
export async function login(login, password) { export async function login(login, password) {
return apiFetch('/login', { return apiFetch('/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ login, password }), body: JSON.stringify({ login, password }),
}) })
} }
export async function registerUser({ email, username, plainPassword }) { export async function registerUser({ email, username, plainPassword }) {
return apiFetch('/register', { return apiFetch('/register', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, username, plainPassword }), body: JSON.stringify({ email, username, plainPassword }),
}) })
} }
export async function listRootForums() { export async function listRootForums() {
return getCollection('/forums?parent[exists]=false') return getCollection('/forums?parent[exists]=false')
} }
export async function listAllForums() { export async function listAllForums() {
return getCollection('/forums?pagination=false') return getCollection('/forums?pagination=false')
} }
export async function getCurrentUser() { export async function getCurrentUser() {
return apiFetch('/user/me') return apiFetch('/user/me')
} }
export async function updateCurrentUser(payload) { export async function updateCurrentUser(payload) {
return apiFetch('/user/me', { return apiFetch('/user/me', {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/merge-patch+json', 'Content-Type': 'application/merge-patch+json',
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
} }
export async function uploadAvatar(file) { export async function uploadAvatar(file) {
const body = new FormData() const body = new FormData()
body.append('file', file) body.append('file', file)
return apiFetch('/user/avatar', { return apiFetch('/user/avatar', {
method: 'POST', method: 'POST',
body, body,
}) })
} }
export async function getUserProfile(id) { export async function getUserProfile(id) {
return apiFetch(`/user/profile/${id}`) return apiFetch(`/user/profile/${id}`)
}
export async function listUserThanksGiven(id) {
return apiFetch(`/user/${id}/thanks/given`)
}
export async function listUserThanksReceived(id) {
return apiFetch(`/user/${id}/thanks/received`)
} }
export async function fetchVersion() { export async function fetchVersion() {
return apiFetch('/version') return apiFetch('/version')
} }
export async function fetchStats() { export async function fetchStats() {
return apiFetch('/stats') return apiFetch('/stats')
} }
export async function fetchPortalSummary() { export async function fetchPortalSummary() {
return apiFetch('/portal/summary') 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()
const data = await apiFetch( const data = await apiFetch(
`/settings?key=${encodeURIComponent(key)}&pagination=false&_=${cacheBust}`, `/settings?key=${encodeURIComponent(key)}&pagination=false&_=${cacheBust}`,
{ cache: 'no-store' } { cache: 'no-store' }
) )
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data[0] || null return data[0] || null
} }
return data?.['hydra:member']?.[0] || null return data?.['hydra:member']?.[0] || null
} }
export async function fetchSettings() { export async function fetchSettings() {
const cacheBust = Date.now() const cacheBust = Date.now()
const data = await apiFetch(`/settings?pagination=false&_=${cacheBust}`, { cache: 'no-store' }) const data = await apiFetch(`/settings?pagination=false&_=${cacheBust}`, { cache: 'no-store' })
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data return data
} }
return data?.['hydra:member'] || [] return data?.['hydra:member'] || []
} }
export async function saveSetting(key, value) { export async function saveSetting(key, value) {
return apiFetch('/settings', { return apiFetch('/settings', {
method: 'POST', method: 'POST',
body: JSON.stringify({ key, value }), body: JSON.stringify({ key, value }),
}) })
} }
export async function saveSettings(settings) { export async function saveSettings(settings) {
return apiFetch('/settings/bulk', { return apiFetch('/settings/bulk', {
method: 'POST', method: 'POST',
body: JSON.stringify({ settings }), body: JSON.stringify({ settings }),
}) })
} }
export async function uploadLogo(file) { export async function uploadLogo(file) {
const body = new FormData() const body = new FormData()
body.append('file', file) body.append('file', file)
return apiFetch('/uploads/logo', { return apiFetch('/uploads/logo', {
method: 'POST', method: 'POST',
body, body,
}) })
} }
export async function uploadFavicon(file) { export async function uploadFavicon(file) {
const body = new FormData() const body = new FormData()
body.append('file', file) body.append('file', file)
return apiFetch('/uploads/favicon', { return apiFetch('/uploads/favicon', {
method: 'POST', method: 'POST',
body, body,
}) })
} }
export async function fetchUserSetting(key) { export async function fetchUserSetting(key) {
const data = await getCollection(`/user-settings?key=${encodeURIComponent(key)}&pagination=false`) const data = await getCollection(`/user-settings?key=${encodeURIComponent(key)}&pagination=false`)
return data[0] || null return data[0] || null
} }
export async function saveUserSetting(key, value) { export async function saveUserSetting(key, value) {
return apiFetch('/user-settings', { return apiFetch('/user-settings', {
method: 'POST', method: 'POST',
body: JSON.stringify({ key, value }), body: JSON.stringify({ key, value }),
}) })
} }
export async function listForumsByParent(parentId) { export async function listForumsByParent(parentId) {
return getCollection(`/forums?parent=/api/forums/${parentId}`) return getCollection(`/forums?parent=/api/forums/${parentId}`)
} }
export async function getForum(id) { export async function getForum(id) {
return apiFetch(`/forums/${id}`) return apiFetch(`/forums/${id}`)
} }
export async function createForum({ name, description, type, parentId }) { export async function createForum({ name, description, type, parentId }) {
return apiFetch('/forums', { return apiFetch('/forums', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name, name,
description, description,
type, type,
parent: parentId ? `/api/forums/${parentId}` : null, parent: parentId ? `/api/forums/${parentId}` : null,
}), }),
}) })
} }
export async function updateForum(id, { name, description, type, parentId }) { export async function updateForum(id, { name, description, type, parentId }) {
return apiFetch(`/forums/${id}`, { return apiFetch(`/forums/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/merge-patch+json', 'Content-Type': 'application/merge-patch+json',
}, },
body: JSON.stringify({ body: JSON.stringify({
name, name,
description, description,
type, type,
parent: parentId ? `/api/forums/${parentId}` : null, parent: parentId ? `/api/forums/${parentId}` : null,
}), }),
}) })
} }
export async function deleteForum(id) { export async function deleteForum(id) {
return apiFetch(`/forums/${id}`, { return apiFetch(`/forums/${id}`, {
method: 'DELETE', method: 'DELETE',
}) })
} }
export async function reorderForums(parentId, orderedIds) { export async function reorderForums(parentId, orderedIds) {
return apiFetch('/forums/reorder', { return apiFetch('/forums/reorder', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
parentId, parentId,
orderedIds, orderedIds,
}), }),
}) })
} }
export async function listThreadsByForum(forumId) { export async function listThreadsByForum(forumId) {
return getCollection(`/threads?forum=/api/forums/${forumId}`) return getCollection(`/threads?forum=/api/forums/${forumId}`)
} }
export async function listThreads() { export async function listThreads() {
return getCollection('/threads') return getCollection('/threads')
} }
export async function getThread(id) { export async function getThread(id) {
return apiFetch(`/threads/${id}`) return apiFetch(`/threads/${id}`)
} }
export async function listPostsByThread(threadId) { export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`) return getCollection(`/posts?thread=/api/threads/${threadId}`)
} }
export async function listUsers() { export async function listUsers() {
return getCollection('/users') return getCollection('/users')
} }
export async function listRanks() { export async function listRanks() {
return getCollection('/ranks') return getCollection('/ranks')
}
export async function listRoles() {
return getCollection('/roles')
}
export async function createRole(payload) {
return apiFetch('/roles', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRole(roleId, payload) {
return apiFetch(`/roles/${roleId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRole(roleId) {
return apiFetch(`/roles/${roleId}`, {
method: 'DELETE',
})
} }
export async function updateUserRank(userId, rankId) { export async function updateUserRank(userId, rankId) {
return apiFetch(`/users/${userId}/rank`, { return apiFetch(`/users/${userId}/rank`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ rank_id: rankId }), body: JSON.stringify({ rank_id: rankId }),
}) })
} }
export async function createRank(payload) { export async function createRank(payload) {
return apiFetch('/ranks', { return apiFetch('/ranks', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
} }
export async function updateRank(rankId, payload) { export async function updateRank(rankId, payload) {
return apiFetch(`/ranks/${rankId}`, { return apiFetch(`/ranks/${rankId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
} }
export async function deleteRank(rankId) { export async function deleteRank(rankId) {
return apiFetch(`/ranks/${rankId}`, { return apiFetch(`/ranks/${rankId}`, {
method: 'DELETE', method: 'DELETE',
}) })
} }
export async function uploadRankBadgeImage(rankId, file) { export async function uploadRankBadgeImage(rankId, file) {
const body = new FormData() const body = new FormData()
body.append('file', file) body.append('file', file)
return apiFetch(`/ranks/${rankId}/badge-image`, { return apiFetch(`/ranks/${rankId}/badge-image`, {
method: 'POST', method: 'POST',
body, body,
}) })
} }
export async function updateUser(userId, payload) { export async function updateUser(userId, payload) {
return apiFetch(`/users/${userId}`, { return apiFetch(`/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload), 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',
body: JSON.stringify({ body: JSON.stringify({
title, title,
body, body,
forum: `/api/forums/${forumId}`, forum: `/api/forums/${forumId}`,
}), }),
}) })
} }
export async function createPost({ body, threadId }) { export async function createPost({ body, threadId }) {
return apiFetch('/posts', { return apiFetch('/posts', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
body, body,
thread: `/api/threads/${threadId}`, thread: `/api/threads/${threadId}`,
}), }),
}) })
} }

View File

@@ -2,90 +2,106 @@ import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) { export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) {
const { t } = useTranslation() const { t } = useTranslation()
const authorName = thread.user_name || t('thread.anonymous') const authorName = thread.user_name || t('thread.anonymous')
const lastAuthorName = thread.last_post_user_name || authorName const lastAuthorName = thread.last_post_user_name || authorName
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : '' const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
const authorLinkColor = thread.user_rank_color || thread.user_group_color
const authorLinkStyle = authorLinkColor
? { '--bb-user-link-color': authorLinkColor }
: undefined
const lastAuthorLinkColor = thread.last_post_user_rank_color || thread.last_post_user_group_color
const lastAuthorLinkStyle = lastAuthorLinkColor
? { '--bb-user-link-color': lastAuthorLinkColor }
: undefined
const formatDateTime = (value) => { const formatDateTime = (value) => {
if (!value) return '—' if (!value) return '—'
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—' if (Number.isNaN(date.getTime())) return '—'
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()) const year = String(date.getFullYear())
const hours = String(date.getHours()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0') const seconds = String(date.getSeconds()).padStart(2, '0')
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}` return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
} }
return ( return (
<div className="bb-portal-topic-row"> <div className="bb-portal-topic-row">
<div className="bb-portal-topic-main"> <div className="bb-portal-topic-main">
<span className="bb-portal-topic-icon" aria-hidden="true"> <span className="bb-portal-topic-icon" aria-hidden="true">
<i className="bi bi-chat-left-text" /> <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> </span>
</div> <div>
)} <Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
</div> {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"
style={authorLinkStyle}
>
{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"
style={lastAuthorLinkStyle}
>
{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> </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

@@ -4,92 +4,92 @@ import { login as apiLogin } from '../api/client'
const AuthContext = createContext(null) const AuthContext = createContext(null)
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem('speedbb_token')) const [token, setToken] = useState(() => localStorage.getItem('speedbb_token'))
const [email, setEmail] = useState(() => localStorage.getItem('speedbb_email')) const [email, setEmail] = useState(() => localStorage.getItem('speedbb_email'))
const [userId, setUserId] = useState(() => { const [userId, setUserId] = useState(() => {
const stored = localStorage.getItem('speedbb_user_id') const stored = localStorage.getItem('speedbb_user_id')
if (stored) return stored if (stored) return stored
return null return null
}) })
const [roles, setRoles] = useState(() => { const [roles, setRoles] = useState(() => {
const stored = localStorage.getItem('speedbb_roles') const stored = localStorage.getItem('speedbb_roles')
if (stored) return JSON.parse(stored) if (stored) return JSON.parse(stored)
return [] return []
})
const effectiveRoles = token ? roles : []
const effectiveUserId = token ? userId : null
const value = useMemo(
() => ({
token,
email,
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id))
}
if (Array.isArray(data.roles)) {
localStorage.setItem('speedbb_roles', JSON.stringify(data.roles))
setRoles(data.roles)
} else {
localStorage.removeItem('speedbb_roles')
setRoles([])
}
setToken(data.token)
setEmail(data.email || loginInput)
},
logout() {
localStorage.removeItem('speedbb_token')
localStorage.removeItem('speedbb_email')
localStorage.removeItem('speedbb_user_id')
localStorage.removeItem('speedbb_roles')
setToken(null)
setEmail(null)
setUserId(null)
setRoles([])
},
}),
[token, email, effectiveUserId, effectiveRoles]
)
useEffect(() => {
const handleUnauthorized = () => {
setToken(null)
setEmail(null)
setUserId(null)
setRoles([])
}
window.addEventListener('speedbb-unauthorized', handleUnauthorized)
return () => window.removeEventListener('speedbb-unauthorized', handleUnauthorized)
}, [])
useEffect(() => {
console.log('speedBB auth', {
email,
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
}) })
}, [email, effectiveUserId, effectiveRoles, token])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> const effectiveRoles = token ? roles : []
const effectiveUserId = token ? userId : null
const value = useMemo(
() => ({
token,
email,
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id))
}
if (Array.isArray(data.roles)) {
localStorage.setItem('speedbb_roles', JSON.stringify(data.roles))
setRoles(data.roles)
} else {
localStorage.removeItem('speedbb_roles')
setRoles([])
}
setToken(data.token)
setEmail(data.email || loginInput)
},
logout() {
localStorage.removeItem('speedbb_token')
localStorage.removeItem('speedbb_email')
localStorage.removeItem('speedbb_user_id')
localStorage.removeItem('speedbb_roles')
setToken(null)
setEmail(null)
setUserId(null)
setRoles([])
},
}),
[token, email, effectiveUserId, effectiveRoles]
)
useEffect(() => {
const handleUnauthorized = () => {
setToken(null)
setEmail(null)
setUserId(null)
setRoles([])
}
window.addEventListener('speedbb-unauthorized', handleUnauthorized)
return () => window.removeEventListener('speedbb-unauthorized', handleUnauthorized)
}, [])
useEffect(() => {
console.log('speedBB auth', {
email,
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
})
}, [email, effectiveUserId, effectiveRoles, token])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }
export function useAuth() { export function useAuth() {
const ctx = useContext(AuthContext) const ctx = useContext(AuthContext)
if (!ctx) { if (!ctx) {
throw new Error('useAuth must be used within AuthProvider') throw new Error('useAuth must be used within AuthProvider')
} }
return ctx return ctx
} }

View File

@@ -5,21 +5,21 @@ import { initReactI18next } from 'react-i18next'
const storedLanguage = localStorage.getItem('speedbb_lang') || 'en' const storedLanguage = localStorage.getItem('speedbb_lang') || 'en'
i18n i18n
.use(HttpBackend) .use(HttpBackend)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
lng: storedLanguage, lng: storedLanguage,
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['en', 'de'], supportedLngs: ['en', 'de'],
backend: { backend: {
loadPath: '/api/i18n/{{lng}}', loadPath: '/api/i18n/{{lng}}',
}, },
react: { react: {
useSuspense: false, useSuspense: false,
}, },
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
}) })
export default i18n export default i18n

View File

@@ -360,18 +360,41 @@ a {
transition: border-color 0.15s ease, color 0.15s ease; transition: border-color 0.15s ease, color 0.15s ease;
} }
.bb-post-footer .bb-post-action {
width: 28px;
height: 28px;
font-size: 0.9rem;
}
.bb-post-action:hover { .bb-post-action:hover {
color: var(--bb-accent, #f29b3f); color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f); border-color: var(--bb-accent, #f29b3f);
} }
.bb-post-action--round {
border-radius: 50%;
}
.bb-post-content {
position: relative;
padding-bottom: 3.5rem;
}
.bb-post-body { .bb-post-body {
white-space: pre-wrap; white-space: pre-wrap;
color: var(--bb-ink); color: var(--bb-ink);
line-height: 1.6; line-height: 1.6;
} }
.bb-post-footer {
position: absolute;
right: 1rem;
bottom: 1rem;
display: flex;
justify-content: flex-end;
}
.bb-thread-reply { .bb-thread-reply {
border: 1px solid var(--bb-border); border: 1px solid var(--bb-border);
border-radius: 16px; border-radius: 16px;
@@ -1194,12 +1217,12 @@ a {
} }
.bb-board-last-link { .bb-board-last-link {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600; font-weight: 600;
} }
.bb-board-last-link:hover { .bb-board-last-link:hover {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none; text-decoration: none;
} }
@@ -1361,6 +1384,45 @@ a {
color: var(--bb-ink); color: var(--bb-ink);
} }
.bb-profile-thanks {
display: grid;
gap: 1.5rem;
}
.bb-profile-thanks-list {
list-style: none;
padding: 0;
margin: 0.6rem 0 0;
display: grid;
gap: 0.6rem;
}
.bb-profile-thanks-item {
display: grid;
gap: 0.2rem;
color: var(--bb-ink-muted);
}
.bb-profile-thanks-item a {
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600;
}
.bb-profile-thanks-item a:hover {
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none;
}
.bb-profile-thanks-meta {
color: var(--bb-ink-muted);
font-weight: 500;
}
.bb-profile-thanks-date {
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-portal-list { .bb-portal-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -1474,12 +1536,12 @@ a {
} }
.bb-portal-topic-author { .bb-portal-topic-author {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600; font-weight: 600;
} }
.bb-portal-topic-author:hover { .bb-portal-topic-author:hover {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none; text-decoration: none;
} }
@@ -1516,12 +1578,12 @@ a {
} }
.bb-portal-last-user { .bb-portal-last-user {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600; font-weight: 600;
} }
.bb-portal-last-user:hover { .bb-portal-last-user:hover {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none; text-decoration: none;
} }
@@ -1572,11 +1634,11 @@ a {
} }
.bb-portal-user-name-link { .bb-portal-user-name-link {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
} }
.bb-portal-user-name-link:hover { .bb-portal-user-name-link:hover {
color: var(--bb-accent, #f29b3f); color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none; text-decoration: none;
} }
@@ -1830,6 +1892,15 @@ a {
width: auto; width: auto;
} }
.bb-rank-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
flex: 0 0 auto;
}
.bb-rank-badge { .bb-rank-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1863,6 +1934,146 @@ a {
.bb-rank-actions { .bb-rank-actions {
display: inline-flex; display: inline-flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center;
}
.bb-multiselect {
position: relative;
}
.bb-multiselect__control {
width: 100%;
min-height: 42px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(14, 18, 27, 0.6);
color: var(--bb-ink);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.45rem 0.6rem;
text-align: left;
}
.bb-multiselect__control:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bb-multiselect__value {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.bb-multiselect__placeholder {
color: var(--bb-ink-muted);
}
.bb-multiselect__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 0.85rem;
}
.bb-multiselect__chip-color {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}
.bb-multiselect__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
font-size: 0.8rem;
line-height: 1;
}
.bb-multiselect__caret {
color: var(--bb-ink-muted);
}
.bb-multiselect__menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 20;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(12, 16, 24, 0.95);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.bb-multiselect__search {
padding: 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bb-multiselect__search input {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(16, 20, 28, 0.8);
color: var(--bb-ink);
padding: 0.35rem 0.5rem;
}
.bb-multiselect__options {
max-height: 220px;
overflow-y: auto;
}
.bb-multiselect__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.75rem;
background: transparent;
border: none;
color: var(--bb-ink);
text-align: left;
}
.bb-multiselect__option:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bb-btn-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bb-multiselect__option:hover,
.bb-multiselect__option.is-selected {
background: rgba(255, 255, 255, 0.08);
}
.bb-multiselect__option-main {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.bb-multiselect__empty {
padding: 0.75rem;
color: var(--bb-ink-muted);
} }
.bb-user-search { .bb-user-search {

View File

@@ -7,7 +7,7 @@ import './i18n'
import App from './App.jsx' import App from './App.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) )

File diff suppressed because it is too large Load Diff

View File

@@ -6,225 +6,236 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
export default function BoardIndex() { export default function BoardIndex() {
const [forums, setForums] = useState([]) const [forums, setForums] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState({}) const [collapsed, setCollapsed] = useState({})
const { t } = useTranslation() const { t } = useTranslation()
const { token } = useAuth() const { token } = useAuth()
const collapsedKey = 'board_index.collapsed_categories' const collapsedKey = 'board_index.collapsed_categories'
const storageKey = `speedbb_user_setting_${collapsedKey}` const storageKey = `speedbb_user_setting_${collapsedKey}`
const saveTimer = useRef(null) const saveTimer = useRef(null)
useEffect(() => { useEffect(() => {
listAllForums() listAllForums()
.then(setForums) .then(setForums)
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!token) return if (!token) return
let active = true let active = true
const cached = localStorage.getItem(storageKey) const cached = localStorage.getItem(storageKey)
if (cached) { if (cached) {
try { try {
const parsed = JSON.parse(cached) const parsed = JSON.parse(cached)
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const next = {} const next = {}
parsed.forEach((id) => { parsed.forEach((id) => {
next[String(id)] = true next[String(id)] = true
}) })
setCollapsed(next) setCollapsed(next)
}
} catch {
localStorage.removeItem(storageKey)
}
} }
} catch {
localStorage.removeItem(storageKey)
}
}
fetchUserSetting(collapsedKey) fetchUserSetting(collapsedKey)
.then((setting) => { .then((setting) => {
if (!active) return if (!active) return
const next = {} const next = {}
if (Array.isArray(setting?.value)) { if (Array.isArray(setting?.value)) {
setting.value.forEach((id) => { setting.value.forEach((id) => {
next[String(id)] = true next[String(id)] = true
}) })
}
setCollapsed(next)
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
})
.catch(() => {})
return () => {
active = false
} }
setCollapsed(next) }, [token])
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
})
.catch(() => {})
return () => { const getParentId = (forum) => {
active = false if (!forum.parent) return null
} if (typeof forum.parent === 'string') {
}, [token]) return forum.parent.split('/').pop()
}
const getParentId = (forum) => { return forum.parent.id ?? null
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
})
forums.forEach((forum) => {
const parentId = getParentId(forum)
const node = map.get(String(forum.id))
if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
const sortNodes = (nodes) => {
nodes.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.name.localeCompare(b.name)
})
nodes.forEach((node) => sortNodes(node.children))
} }
sortNodes(roots) const forumTree = useMemo(() => {
const map = new Map()
const roots = []
return roots forums.forEach((forum) => {
}, [forums]) map.set(String(forum.id), { ...forum, children: [] })
})
const renderRows = (nodes) => forums.forEach((forum) => {
nodes.map((node) => ( const parentId = getParentId(forum)
<div className="bb-board-row" key={node.id}> const node = map.get(String(forum.id))
<div className="bb-board-cell bb-board-cell--title"> if (parentId && map.has(String(parentId))) {
<div className="bb-board-title"> map.get(String(parentId)).children.push(node)
<span className="bb-board-icon" aria-hidden="true"> } else {
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} /> roots.push(node)
</span> }
<div> })
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name} const sortNodes = (nodes) => {
</Link> nodes.sort((a, b) => {
<div className="bb-board-desc">{node.description || ''}</div> if (a.position !== b.position) return a.position - b.position
{node.children?.length > 0 && ( return a.name.localeCompare(b.name)
<div className="bb-board-subforums"> })
{t('forum.children')}:{' '} nodes.forEach((node) => sortNodes(node.children))
{node.children.map((child, index) => ( }
<span key={child.id}>
<Link to={`/forum/${child.id}`} className="bb-board-subforum-link"> sortNodes(roots)
{child.name}
</Link> return roots
{index < node.children.length - 1 ? ', ' : ''} }, [forums])
</span>
))} const renderRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
{node.children?.length > 0 && (
<div className="bb-board-subforums">
{t('forum.children')}:{' '}
{node.children.map((child, index) => (
<span key={child.id}>
<Link to={`/forum/${child.id}`} className="bb-board-subforum-link">
{child.name}
</Link>
{index < node.children.length - 1 ? ', ' : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<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"
style={
node.last_post_user_rank_color || node.last_post_user_group_color
? {
'--bb-user-link-color':
node.last_post_user_rank_color || node.last_post_user_group_color,
}
: undefined
}
>
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">
{node.last_post_at.slice(0, 10)}
</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div> </div>
)}
</div> </div>
</div> ))
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<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>
)}
</div>
</div>
))
return ( return (
<Container fluid 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 && (
<p className="bb-muted">{t('home.empty')}</p> <p className="bb-muted">{t('home.empty')}</p>
)} )}
{forumTree.length > 0 && ( {forumTree.length > 0 && (
<div className="bb-board-index"> <div className="bb-board-index">
{forumTree.map((category) => ( {forumTree.map((category) => (
<section className="bb-board-section" key={category.id}> <section className="bb-board-section" key={category.id}>
<header className="bb-board-section__header"> <header className="bb-board-section__header">
<span className="bb-board-section__title">{category.name}</span> <span className="bb-board-section__title">{category.name}</span>
<div className="bb-board-section__controls"> <div className="bb-board-section__controls">
<div className="bb-board-section__cols"> <div className="bb-board-section__cols">
<span>{t('portal.topic')}</span> <span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span> <span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span> <span>{t('thread.last_post')}</span>
</div> </div>
<button <button
type="button" type="button"
className="bb-board-toggle" className="bb-board-toggle"
onClick={() => onClick={() =>
setCollapsed((prev) => { setCollapsed((prev) => {
const next = { const next = {
...prev, ...prev,
[category.id]: !prev[category.id], [category.id]: !prev[category.id],
} }
const collapsedIds = Object.keys(next).filter((key) => next[key]) const collapsedIds = Object.keys(next).filter((key) => next[key])
localStorage.setItem(storageKey, JSON.stringify(collapsedIds)) localStorage.setItem(storageKey, JSON.stringify(collapsedIds))
if (token) { if (token) {
if (saveTimer.current) { if (saveTimer.current) {
clearTimeout(saveTimer.current) clearTimeout(saveTimer.current)
} }
saveTimer.current = setTimeout(() => { saveTimer.current = setTimeout(() => {
saveUserSetting(collapsedKey, collapsedIds).catch(() => {}) saveUserSetting(collapsedKey, collapsedIds).catch(() => {})
}, 400) }, 400)
} }
return next return next
}) })
} }
aria-label={ aria-label={
collapsed[category.id] collapsed[category.id]
? t('forum.expand_category') ? t('forum.expand_category')
: t('forum.collapse_category') : t('forum.collapse_category')
} }
> >
<i <i
className={`bi ${ className={`bi ${
collapsed[category.id] ? 'bi-plus-square' : 'bi-dash-square' collapsed[category.id] ? 'bi-plus-square' : 'bi-dash-square'
}`} }`}
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
</div>
</header>
{!collapsed[category.id] && (
<div className="bb-board-section__body">
{category.children?.length > 0 ? (
renderRows(category.children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
)}
</section>
))}
</div> </div>
</header> )}
{!collapsed[category.id] && ( </Container>
<div className="bb-board-section__body"> )
{category.children?.length > 0 ? (
renderRows(category.children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
)}
</section>
))}
</div>
)}
</Container>
)
} }

View File

@@ -7,241 +7,252 @@ import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function ForumView() { export default function ForumView() {
const { id } = useParams() const { id } = useParams()
const { token } = useAuth() const { token } = useAuth()
const [forum, setForum] = useState(null) const [forum, setForum] = useState(null)
const [children, setChildren] = useState([]) const [children, setChildren] = useState([])
const [threads, setThreads] = useState([]) const [threads, setThreads] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
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 renderChildRows = (nodes) => const renderChildRows = (nodes) =>
nodes.map((node) => ( nodes.map((node) => (
<div className="bb-board-row" key={node.id}> <div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title"> <div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title"> <div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true"> <span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} /> <i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span> </span>
<div> <div>
<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 || ''}</div> <div className="bb-board-desc">{node.description || ''}</div>
</div> </div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<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>
)}
</div>
</div>
))
useEffect(() => {
let active = true
const loadData = async () => {
setLoading(true)
setError('')
try {
const forumData = await getForum(id)
if (!active) return
setForum(forumData)
const childData = await listForumsByParent(id)
if (!active) return
setChildren(childData)
if (forumData.type === 'forum') {
const threadData = await listThreadsByForum(id)
if (!active) return
setThreads(threadData)
} else {
setThreads([])
}
} catch (err) {
if (active) setError(err.message)
} finally {
if (active) setLoading(false)
}
}
loadData()
return () => {
active = false
}
}, [id])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createThread({ title, body, forumId: id })
setTitle('')
setBody('')
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<Row className="g-4">
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div> </div>
</section>
</div> </div>
)} <div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
{forum.type === 'forum' && ( <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-topic-toolbar mt-4 mb-2"> {node.last_post_at ? (
<div className="bb-topic-toolbar__left"> <div className="bb-board-last">
<Button <span className="bb-board-last-by">
variant="dark" {t('thread.by')}{' '}
className="bb-topic-action bb-accent-button" {node.last_post_user_id ? (
onClick={() => setShowModal(true)} <Link
disabled={!token || saving} to={`/profile/${node.last_post_user_id}`}
> className="bb-board-last-link"
<i className="bi bi-pencil me-2" aria-hidden="true" /> style={
{t('forum.start_thread')} node.last_post_user_rank_color || node.last_post_user_group_color
</Button> ? {
</div> '--bb-user-link-color':
<div className="bb-topic-toolbar__right"> node.last_post_user_rank_color || node.last_post_user_group_color,
<span className="bb-topic-count"> }
{threads.length} {t('forum.threads').toLowerCase()} : undefined
</span> }
<div className="bb-topic-pagination"> >
<Button size="sm" variant="outline-secondary" disabled> {node.last_post_user_name || t('thread.anonymous')}
</Link>
</Button> ) : (
<Button size="sm" variant="outline-secondary" className="is-active" disabled> <span>{node.last_post_user_name || t('thread.anonymous')}</span>
1 )}
</Button> </span>
<Button size="sm" variant="outline-secondary" disabled> <span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
</div>
</Button> ) : (
</div> <span className="bb-muted">{t('thread.no_replies')}</span>
</div>
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{threads.length === 0 && (
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
)} )}
{threads.map((thread) => ( </div>
<PortalTopicRow </div>
key={thread.id} ))
thread={thread}
forumName={forum?.name || t('portal.unknown_forum')} useEffect(() => {
forumId={forum?.id} let active = true
showForum={false}
/> const loadData = async () => {
))} setLoading(true)
</div> setError('')
try {
const forumData = await getForum(id)
if (!active) return
setForum(forumData)
const childData = await listForumsByParent(id)
if (!active) return
setChildren(childData)
if (forumData.type === 'forum') {
const threadData = await listThreadsByForum(id)
if (!active) return
setThreads(threadData)
} else {
setThreads([])
}
} catch (err) {
if (active) setError(err.message)
} finally {
if (active) setLoading(false)
}
}
loadData()
return () => {
active = false
}
}, [id])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createThread({ title, body, forumId: id })
setTitle('')
setBody('')
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<Row className="g-4">
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
</section>
</div>
)}
{forum.type === 'forum' && (
<>
<div className="bb-topic-toolbar mt-4 mb-2">
<div className="bb-topic-toolbar__left">
<Button
variant="dark"
className="bb-topic-action bb-accent-button"
onClick={() => setShowModal(true)}
disabled={!token || saving}
>
<i className="bi bi-pencil me-2" aria-hidden="true" />
{t('forum.start_thread')}
</Button>
</div>
<div className="bb-topic-toolbar__right">
<span className="bb-topic-count">
{threads.length} {t('forum.threads').toLowerCase()}
</span>
<div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled>
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
</Button>
</div>
</div>
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{threads.length === 0 && (
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
)}
{threads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={forum?.name || t('portal.unknown_forum')}
forumId={forum?.id}
showForum={false}
/>
))}
</div>
</>
)}
</Col>
</Row>
</> </>
)} )}
</Col> {forum?.type === 'forum' && (
</Row> <Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
</> <Modal.Header closeButton>
)} <Modal.Title>{t('forum.start_thread')}</Modal.Title>
{forum?.type === 'forum' && ( </Modal.Header>
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg"> <Modal.Body>
<Modal.Header closeButton> {!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Modal.Title>{t('forum.start_thread')}</Modal.Title> <Form onSubmit={handleSubmit}>
</Modal.Header> <Form.Group className="mb-3">
<Modal.Body> <Form.Label>{t('form.title')}</Form.Label>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>} <Form.Control
<Form onSubmit={handleSubmit}> type="text"
<Form.Group className="mb-3"> placeholder={t('form.thread_title_placeholder')}
<Form.Label>{t('form.title')}</Form.Label> value={title}
<Form.Control onChange={(event) => setTitle(event.target.value)}
type="text" disabled={!token || saving}
placeholder={t('form.thread_title_placeholder')} required
value={title} />
onChange={(event) => setTitle(event.target.value)} </Form.Group>
disabled={!token || saving} <Form.Group className="mb-3">
required <Form.Label>{t('form.body')}</Form.Label>
/> <Form.Control
</Form.Group> as="textarea"
<Form.Group className="mb-3"> rows={6}
<Form.Label>{t('form.body')}</Form.Label> placeholder={t('form.thread_body_placeholder')}
<Form.Control value={body}
as="textarea" onChange={(event) => setBody(event.target.value)}
rows={6} disabled={!token || saving}
placeholder={t('form.thread_body_placeholder')} required
value={body} />
onChange={(event) => setBody(event.target.value)} </Form.Group>
disabled={!token || saving} <div className="d-flex gap-2 justify-content-between">
required <Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
/> {t('acp.cancel')}
</Form.Group> </Button>
<div className="d-flex gap-2 justify-content-between"> <Button type="submit" className="bb-accent-button" disabled={!token || saving}>
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}> {saving ? t('form.posting') : t('form.create_thread')}
{t('acp.cancel')} </Button>
</Button> </div>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}> </Form>
{saving ? t('form.posting') : t('form.create_thread')} </Modal.Body>
</Button> </Modal>
</div> )}
</Form> </Container>
</Modal.Body> )
</Modal>
)}
</Container>
)
} }

View File

@@ -7,248 +7,256 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
export default function Home() { export default function Home() {
const [forums, setForums] = useState([]) const [forums, setForums] = useState([])
const [threads, setThreads] = useState([]) const [threads, setThreads] = useState([])
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 }) const [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 [loadingStats, setLoadingStats] = useState(true)
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
const { token, roles, email } = useAuth() const { token, roles, email } = useAuth()
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
let active = true let active = true
setLoadingForums(true) setLoadingForums(true)
setLoadingThreads(true) setLoadingThreads(true)
setLoadingStats(true) setLoadingStats(true)
setError('') setError('')
fetchPortalSummary() fetchPortalSummary()
.then((data) => { .then((data) => {
if (!active) return if (!active) return
setForums(data?.forums || []) setForums(data?.forums || [])
setThreads(data?.threads || []) setThreads(data?.threads || [])
setStats({ setStats({
threads: data?.stats?.threads ?? 0, threads: data?.stats?.threads ?? 0,
posts: data?.stats?.posts ?? 0, posts: data?.stats?.posts ?? 0,
users: data?.stats?.users ?? 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) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
}) })
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 () => { forums.forEach((forum) => {
active = false const parentId = getParentId(forum)
} const node = map.get(String(forum.id))
}, [token]) if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
const getParentId = (forum) => { const sortNodes = (nodes) => {
if (!forum.parent) return null nodes.sort((a, b) => {
if (typeof forum.parent === 'string') { if (a.position !== b.position) return a.position - b.position
return forum.parent.split('/').pop() return a.name.localeCompare(b.name)
} })
return forum.parent.id ?? null nodes.forEach((node) => sortNodes(node.children))
} }
const forumTree = useMemo(() => { sortNodes(roots)
const map = new Map()
const roots = []
forums.forEach((forum) => { return roots
map.set(String(forum.id), { ...forum, children: [] }) }, [forums])
})
forums.forEach((forum) => { const forumMap = useMemo(() => {
const parentId = getParentId(forum) const map = new Map()
const node = map.get(String(forum.id)) forums.forEach((forum) => {
if (parentId && map.has(String(parentId))) { map.set(String(forum.id), forum)
map.get(String(parentId)).children.push(node) })
} else { return map
roots.push(node) }, [forums])
}
})
const sortNodes = (nodes) => { const recentThreads = useMemo(() => {
nodes.sort((a, b) => { return [...threads]
if (a.position !== b.position) return a.position - b.position .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
return a.name.localeCompare(b.name) .slice(0, 12)
}) }, [threads])
nodes.forEach((node) => sortNodes(node.children))
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) => {
if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/')
const id = parts[parts.length - 1]
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
} }
sortNodes(roots) const resolveForumId = (thread) => {
if (!thread?.forum) return null
const parts = thread.forum.split('/')
return parts[parts.length - 1] || null
}
return roots const renderTree = (nodes, depth = 0) =>
}, [forums]) nodes.map((node) => (
<div key={node.id}>
const forumMap = useMemo(() => { <div
const map = new Map() className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
forums.forEach((forum) => { style={{ marginLeft: depth * 16 }}
map.set(String(forum.id), forum) >
}) <div className="d-flex align-items-start gap-3">
return map <span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
}, [forums]) <i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
const recentThreads = useMemo(() => { <div>
return [...threads] <Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) {node.name}
.slice(0, 12) </Link>
}, [threads]) <div className="bb-muted">{node.description || ''}</div>
</div>
const roleLabel = useMemo(() => { </div>
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) => {
if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/')
const id = parts[parts.length - 1]
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
}
const resolveForumId = (thread) => {
if (!thread?.forum) return null
const parts = thread.forum.split('/')
return parts[parts.length - 1] || null
}
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
<div
className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
style={{ marginLeft: depth * 16 }}
>
<div className="d-flex align-items-start gap-3">
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
{node.name}
</Link>
<div className="bb-muted">{node.description || ''}</div>
</div>
</div>
</div>
{node.children?.length > 0 && (
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
)}
</div>
))
return (
<Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.menu')}</div>
<ul className="bb-portal-list">
<li>{t('portal.menu_news')}</li>
<li>{t('portal.menu_gallery')}</li>
<li>{t('portal.menu_calendar')}</li>
<li>{t('portal.menu_rules')}</li>
</ul>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span>
<strong>{loadingStats ? '—' : stats.threads}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_users')}</span>
<strong>{loadingStats ? '—' : stats.users}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_posts')}</span>
<strong>{loadingStats ? '—' : stats.posts}</strong>
</div>
</div>
</aside>
<main className="bb-portal-column bb-portal-column--center">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
{!loadingThreads && recentThreads.length === 0 && (
<p className="bb-muted">{t('portal.empty_posts')}</p>
)}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div> </div>
{recentThreads.map((thread) => ( {node.children?.length > 0 && (
<PortalTopicRow <div className="mb-2">{renderTree(node.children, depth + 1)}</div>
key={thread.id}
thread={thread}
forumName={resolveForumName(thread)}
forumId={resolveForumId(thread)}
/>
))}
</div>
)}
</div>
</main>
<aside className="bb-portal-column bb-portal-column--right">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
<div className="bb-portal-user-card">
<Link to="/ucp" className="bb-portal-user-avatar">
{profile?.avatar_url ? (
<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"> ))
<li>{t('portal.user_new_posts')}</li>
<li>{t('portal.user_unread')}</li> return (
<li>{t('portal.user_control_panel')}</li> <Container fluid className="pb-4 bb-portal-shell">
<li>{t('portal.user_logout')}</li> <div className="bb-portal-layout">
</ul> <aside className="bb-portal-column bb-portal-column--left">
</div> <div className="bb-portal-card">
<div className="bb-portal-card bb-portal-card--ad"> <div className="bb-portal-card-title">{t('portal.menu')}</div>
<div className="bb-portal-card-title">{t('portal.advertisement')}</div> <ul className="bb-portal-list">
<div className="bb-portal-ad-box">example.com</div> <li>{t('portal.menu_news')}</li>
</div> <li>{t('portal.menu_gallery')}</li>
</aside> <li>{t('portal.menu_calendar')}</li>
</div> <li>{t('portal.menu_rules')}</li>
{error && <p className="text-danger mt-3">{error}</p>} </ul>
</Container> </div>
) <div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span>
<strong>{loadingStats ? '—' : stats.threads}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_users')}</span>
<strong>{loadingStats ? '—' : stats.users}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_posts')}</span>
<strong>{loadingStats ? '—' : stats.posts}</strong>
</div>
</div>
</aside>
<main className="bb-portal-column bb-portal-column--center">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
{!loadingThreads && recentThreads.length === 0 && (
<p className="bb-muted">{t('portal.empty_posts')}</p>
)}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{recentThreads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={resolveForumName(thread)}
forumId={resolveForumId(thread)}
/>
))}
</div>
)}
</div>
</main>
<aside className="bb-portal-column bb-portal-column--right">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
<div className="bb-portal-user-card">
<Link to="/ucp" className="bb-portal-user-avatar">
{profile?.avatar_url ? (
<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"
style={
profile?.rank?.color || profile?.group_color
? { '--bb-user-link-color': profile.rank?.color || profile.group_color }
: undefined
}
>
{profile?.name || email || 'User'}
</Link>
) : (
profile?.name || email || 'User'
)}
</div>
<div className="bb-portal-user-role">{roleLabel}</div>
</div>
<ul className="bb-portal-list">
<li>{t('portal.user_new_posts')}</li>
<li>{t('portal.user_unread')}</li>
<li>{t('portal.user_control_panel')}</li>
<li>{t('portal.user_logout')}</li>
</ul>
</div>
<div className="bb-portal-card bb-portal-card--ad">
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
<div className="bb-portal-ad-box">example.com</div>
</div>
</aside>
</div>
{error && <p className="text-danger mt-3">{error}</p>}
</Container>
)
} }

View File

@@ -5,61 +5,61 @@ import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next' 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 [loginValue, setLoginValue] = 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)
const { t } = useTranslation() const { t } = useTranslation()
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault() event.preventDefault()
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
await login(loginValue, password) await login(loginValue, password)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
setLoading(false) setLoading(false)
}
} }
}
return ( return (
<Container fluid 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>
<Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text> <Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
{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('auth.login_identifier')}</Form.Label> <Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control <Form.Control
type="text" type="text"
value={loginValue} value={loginValue}
onChange={(event) => setLoginValue(event.target.value)} onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')} placeholder={t('auth.login_placeholder')}
required required
/> />
</Form.Group> </Form.Group>
<Form.Group className="mb-4"> <Form.Group className="mb-4">
<Form.Label>{t('form.password')}</Form.Label> <Form.Label>{t('form.password')}</Form.Label>
<Form.Control <Form.Control
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
required required
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.signing_in') : t('form.sign_in')} {loading ? t('form.signing_in') : t('form.sign_in')}
</Button> </Button>
</Form> </Form>
</Card.Body> </Card.Body>
</Card> </Card>
</Container> </Container>
) )
} }

View File

@@ -2,64 +2,177 @@ import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap' import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getUserProfile } from '../api/client' import { Link } from 'react-router-dom'
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } from '../api/client'
export default function Profile() { export default function Profile() {
const { id } = useParams() const { id } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [thanksGiven, setThanksGiven] = useState([])
const [thanksReceived, setThanksReceived] = useState([])
const [loadingThanks, setLoadingThanks] = useState(true)
useEffect(() => { useEffect(() => {
let active = true let active = true
setLoading(true) setLoading(true)
setError('') setError('')
getUserProfile(id) Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
.then((data) => { .then(([profileData, givenData, receivedData]) => {
if (!active) return if (!active) return
setProfile(data) setProfile(profileData)
}) setThanksGiven(Array.isArray(givenData) ? givenData : [])
.catch((err) => { setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
if (!active) return })
setError(err.message) .catch((err) => {
}) if (!active) return
.finally(() => { setError(err.message)
if (active) setLoading(false) setThanksGiven([])
}) setThanksReceived([])
})
.finally(() => {
if (!active) return
setLoading(false)
setLoadingThanks(false)
})
return () => { return () => {
active = false active = false
}
}, [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}`
} }
}, [id])
return ( return (
<Container fluid className="py-5 bb-portal-shell"> <Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card"> <div className="bb-portal-card">
<div className="bb-portal-card-title">{t('profile.title')}</div> <div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>} {loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
{profile && ( {profile && (
<div className="bb-profile"> <div className="bb-profile">
<div className="bb-profile-avatar"> <div className="bb-profile-avatar">
{profile.avatar_url ? ( {profile.avatar_url ? (
<img src={profile.avatar_url} alt="" /> <img src={profile.avatar_url} alt="" />
) : ( ) : (
<i className="bi bi-person" aria-hidden="true" /> <i className="bi bi-person" aria-hidden="true" />
)} )}
</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>
)}
{profile && (
<div className="bb-profile-thanks mt-4">
<div className="bb-profile-section">
<div className="bb-portal-card-title">{t('profile.thanks_given')}</div>
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
{!loadingThanks && thanksGiven.length === 0 && (
<p className="bb-muted">{t('profile.thanks_empty')}</p>
)}
{!loadingThanks && thanksGiven.length > 0 && (
<ul className="bb-profile-thanks-list">
{thanksGiven.map((item) => (
<li key={item.id} className="bb-profile-thanks-item">
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
{item.thread_title || t('thread.label')}
</Link>
{item.post_author_id ? (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_for')}{' '}
<Link
to={`/profile/${item.post_author_id}`}
style={
item.post_author_rank_color || item.post_author_group_color
? {
'--bb-user-link-color':
item.post_author_rank_color || item.post_author_group_color,
}
: undefined
}
>
{item.post_author_name || t('thread.anonymous')}
</Link>
</span>
) : (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_for')} {item.post_author_name || t('thread.anonymous')}
</span>
)}
<span className="bb-profile-thanks-date">
{formatDateTime(item.thanked_at)}
</span>
</li>
))}
</ul>
)}
</div>
<div className="bb-profile-section">
<div className="bb-portal-card-title">{t('profile.thanks_received')}</div>
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
{!loadingThanks && thanksReceived.length === 0 && (
<p className="bb-muted">{t('profile.thanks_empty')}</p>
)}
{!loadingThanks && thanksReceived.length > 0 && (
<ul className="bb-profile-thanks-list">
{thanksReceived.map((item) => (
<li key={item.id} className="bb-profile-thanks-item">
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
{item.thread_title || t('thread.label')}
</Link>
{item.thanker_id ? (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_by')}{' '}
<Link
to={`/profile/${item.thanker_id}`}
style={
item.thanker_rank_color || item.thanker_group_color
? {
'--bb-user-link-color':
item.thanker_rank_color || item.thanker_group_color,
}
: undefined
}
>
{item.thanker_name || t('thread.anonymous')}
</Link>
</span>
) : (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_by')} {item.thanker_name || t('thread.anonymous')}
</span>
)}
<span className="bb-profile-thanks-date">
{formatDateTime(item.thanked_at)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div> </div>
<div className="bb-profile-meta"> </Container>
<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

@@ -5,76 +5,76 @@ import { registerUser } from '../api/client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function Register() { export default function Register() {
const navigate = useNavigate() const navigate = useNavigate()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [plainPassword, setPlainPassword] = useState('') const [plainPassword, setPlainPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [notice, setNotice] = useState('') const [notice, setNotice] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault() event.preventDefault()
setError('') setError('')
setNotice('') setNotice('')
setLoading(true) setLoading(true)
try { try {
await registerUser({ email, username, plainPassword }) await registerUser({ email, username, plainPassword })
setNotice(t('auth.verify_notice')) setNotice(t('auth.verify_notice'))
setEmail('') setEmail('')
setUsername('') setUsername('')
setPlainPassword('') setPlainPassword('')
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
setLoading(false) setLoading(false)
}
} }
}
return ( return (
<Container fluid 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>
<Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text> <Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
{notice && <p className="text-success">{notice}</p>} {notice && <p className="text-success">{notice}</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('form.email')}</Form.Label>
<Form.Control <Form.Control
type="email" type="email"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
required required
/> />
</Form.Group> </Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>{t('form.username')}</Form.Label> <Form.Label>{t('form.username')}</Form.Label>
<Form.Control <Form.Control
type="text" type="text"
value={username} value={username}
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
required required
/> />
</Form.Group> </Form.Group>
<Form.Group className="mb-4"> <Form.Group className="mb-4">
<Form.Label>{t('form.password')}</Form.Label> <Form.Label>{t('form.password')}</Form.Label>
<Form.Control <Form.Control
type="password" type="password"
value={plainPassword} value={plainPassword}
onChange={(event) => setPlainPassword(event.target.value)} onChange={(event) => setPlainPassword(event.target.value)}
minLength={8} minLength={8}
required required
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.registering') : t('form.create_account')} {loading ? t('form.registering') : t('form.create_account')}
</Button> </Button>
</Form> </Form>
</Card.Body> </Card.Body>
</Card> </Card>
</Container> </Container>
) )
} }

View File

@@ -1,278 +1,305 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Container, Form } from 'react-bootstrap' import { Button, Container, Form } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom' import { 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'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function ThreadView() { export default function ThreadView() {
const { id } = useParams() const { id } = useParams()
const { token } = useAuth() const { token, userId } = useAuth()
const [thread, setThread] = useState(null) const [thread, setThread] = useState(null)
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
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) const replyRef = useRef(null)
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
Promise.all([getThread(id), listPostsByThread(id)]) Promise.all([getThread(id), listPostsByThread(id)])
.then(([threadData, postData]) => { .then(([threadData, postData]) => {
setThread(threadData) setThread(threadData)
setPosts(postData) setPosts(postData)
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [id]) }, [id])
useEffect(() => { useEffect(() => {
if (!thread && posts.length === 0) return if (!thread && posts.length === 0) return
const hash = window.location.hash const hash = window.location.hash
if (!hash) return if (!hash) return
const targetId = hash.replace('#', '') const targetId = hash.replace('#', '')
if (!targetId) return if (!targetId) return
const target = document.getElementById(targetId) const target = document.getElementById(targetId)
if (target) { if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' }) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [thread, posts])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createPost({ body, threadId: id })
setBody('')
const updated = await listPostsByThread(id)
setPosts(updated)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
} }
}, [thread, posts])
const handleSubmit = async (event) => { // const replyCount = posts.length
event.preventDefault() const formatDate = (value) => {
setSaving(true) if (!value) return '—'
setError('') const date = new Date(value)
try { if (Number.isNaN(date.getTime())) return '—'
await createPost({ body, threadId: id }) const day = String(date.getDate()).padStart(2, '0')
setBody('') const month = String(date.getMonth() + 1).padStart(2, '0')
const updated = await listPostsByThread(id) const year = String(date.getFullYear())
setPosts(updated) return `${day}.${month}.${year}`
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
} }
} const allPosts = useMemo(() => {
if (!thread) return posts
const rootPost = {
id: `thread-${thread.id}`,
body: thread.body,
created_at: thread.created_at,
user_id: thread.user_id,
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_thanks_given_count: thread.user_thanks_given_count,
user_thanks_received_count: thread.user_thanks_received_count,
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 replyCount = posts.length const handleJumpToReply = () => {
const formatDate = (value) => { replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
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 = () => { const totalPosts = allPosts.length
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const totalPosts = allPosts.length return (
<Container fluid className="py-4 bb-shell-container">
return ( {loading && <p className="bb-muted">{t('thread.loading')}</p>}
<Container fluid className="py-4 bb-shell-container"> {error && <p className="text-danger">{error}</p>}
{loading && <p className="bb-muted">{t('thread.loading')}</p>} {thread && (
{error && <p className="text-danger">{error}</p>} <div className="bb-thread">
{thread && ( <div className="bb-thread-titlebar">
<div className="bb-thread"> <h1 className="bb-thread-title">{thread.title}</h1>
<div className="bb-thread-titlebar"> <div className="bb-thread-meta">
<h1 className="bb-thread-title">{thread.title}</h1> <span>{t('thread.by')}</span>
<div className="bb-thread-meta"> <span className="bb-thread-author">
<span>{t('thread.by')}</span> {thread.user_name || t('thread.anonymous')}
<span className="bb-thread-author"> </span>
{thread.user_name || t('thread.anonymous')} {thread.created_at && (
</span> <span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
{thread.created_at && ( )}
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span> </div>
)}
</div>
</div>
<div className="bb-thread-toolbar">
<div className="bb-thread-actions">
<Button className="bb-accent-button" onClick={handleJumpToReply}>
<i className="bi bi-reply-fill" aria-hidden="true" />
<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" />
)}
</div> </div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role"> <div className="bb-thread-toolbar">
{post.user_rank_name || ''} <div className="bb-thread-actions">
<Button className="bb-accent-button" onClick={handleJumpToReply}>
<i className="bi bi-reply-fill" aria-hidden="true" />
<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>
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
<div className="bb-post-author-badge"> <div className="bb-posts">
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? ( {allPosts.map((post, index) => {
<img src={post.user_rank_badge_url} alt="" /> const authorName = post.author?.username
) : ( || post.user_name
<span>{post.user_rank_badge_text}</span> || post.author_name
|| t('thread.anonymous')
const currentUserId = Number(userId)
const postUserId = Number(post.user_id)
const canThank = Number.isFinite(currentUserId)
&& Number.isFinite(postUserId)
&& currentUserId !== postUserId
console.log('canThank check', {
postId: post.id,
postUserId,
currentUserId,
canThank,
})
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" />
)}
</div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">
{post.user_rank_name || ''}
</div>
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
<div className="bb-post-author-badge">
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
<img src={post.user_rank_badge_url} alt="" />
) : (
<span>{post.user_rank_badge_text}</span>
)}
</div>
)}
<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">{t('thread.thanks_given')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_given_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_received_count ?? 0}
</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>
{canThank && (
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="bb-post-body">{post.body}</div>
<div className="bb-post-footer">
<div className="bb-post-actions">
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
<i className="bi bi-chevron-up" aria-hidden="true" />
</a>
</div>
</div>
</div>
</article>
)
})}
</div>
<div className="bb-thread-reply" ref={replyRef}>
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
{!token && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
)} )}
</div> <Form onSubmit={handleSubmit}>
)} <Form.Group className="mb-3">
<div className="bb-post-author-meta"> <Form.Label>{t('form.message')}</Form.Label>
<div className="bb-post-author-stat"> <Form.Control
<span className="bb-post-author-label">{t('thread.posts')}:</span> as="textarea"
<span className="bb-post-author-value"> rows={6}
{post.user_posts_count ?? 0} placeholder={t('form.reply_placeholder')}
</span> value={body}
</div> onChange={(event) => setBody(event.target.value)}
<div className="bb-post-author-stat"> disabled={!token || saving}
<span className="bb-post-author-label">{t('thread.registered')}:</span> required
<span className="bb-post-author-value"> />
{formatDate(post.user_created_at)} </Form.Group>
</span> <div className="bb-thread-reply-actions">
</div> <Button type="submit" className="bb-accent-button" disabled={!token || saving}>
<div className="bb-post-author-stat"> {saving ? t('form.posting') : t('form.post_reply')}
<span className="bb-post-author-label">{t('thread.location')}:</span> </Button>
<span className="bb-post-author-value"> </div>
{post.user_location || '-'} </Form>
</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> </div>
</aside> </div>
<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 && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
)} )}
<Form onSubmit={handleSubmit}> </Container>
<Form.Group className="mb-3"> )
<Form.Label>{t('form.message')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="bb-thread-reply-actions">
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')}
</Button>
</div>
</Form>
</div>
</div>
)}
</Container>
)
} }

View File

@@ -5,175 +5,175 @@ 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 { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system' const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('') const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false) const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('') const [avatarPreview, setAvatarPreview] = useState('')
const [location, setLocation] = useState('') const [location, setLocation] = useState('')
const [profileError, setProfileError] = useState('') const [profileError, setProfileError] = useState('')
const [profileSaving, setProfileSaving] = useState(false) const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false) const [profileSaved, setProfileSaved] = useState(false)
useEffect(() => { useEffect(() => {
if (!token) return if (!token) return
let active = true let active = true
getCurrentUser() getCurrentUser()
.then((data) => { .then((data) => {
if (!active) return if (!active) return
setAvatarPreview(data?.avatar_url || '') setAvatarPreview(data?.avatar_url || '')
setLocation(data?.location || '') setLocation(data?.location || '')
}) })
.catch(() => { .catch(() => {
if (active) setAvatarPreview('') if (active) setAvatarPreview('')
}) })
return () => { return () => {
active = false active = false
}
}, [token])
const handleLanguageChange = (event) => {
const locale = event.target.value
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
} }
}, [token])
const handleLanguageChange = (event) => { return (
const locale = event.target.value <Container fluid className="py-5 bb-portal-shell">
i18n.changeLanguage(locale) <div className="bb-portal-card mb-4">
localStorage.setItem('speedbb_lang', locale) <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">
return ( <Col md="auto">
<Container fluid className="py-5 bb-portal-shell"> <div className="bb-avatar-preview">
<div className="bb-portal-card mb-4"> {avatarPreview ? (
<div className="bb-portal-card-title">{t('ucp.profile')}</div> <img src={avatarPreview} alt="" />
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p> ) : (
<Row className="g-3 align-items-center"> <i className="bi bi-person" aria-hidden="true" />
<Col md="auto"> )}
<div className="bb-avatar-preview"> </div>
{avatarPreview ? ( </Col>
<img src={avatarPreview} alt="" /> <Col>
) : ( {avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<i className="bi bi-person" aria-hidden="true" /> <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>
</Col> <div className="bb-portal-card">
<Col> <div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>} <p className="bb-muted mb-4">{t('ucp.intro')}</p>
<Form.Group> <Row className="g-3">
<Form.Label>{t('ucp.avatar_label')}</Form.Label> <Col xs={12}>
<Form.Control <Form.Group>
type="file" <Form.Label>{t('nav.language')}</Form.Label>
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" <Form.Select value={i18n.language} onChange={handleLanguageChange}>
disabled={!token || avatarUploading} <option value="en">English</option>
onChange={async (event) => { <option value="de">Deutsch</option>
const file = event.target.files?.[0] </Form.Select>
if (!file) return </Form.Group>
setAvatarError('') </Col>
setAvatarUploading(true) <Col md={6}>
try { <Form.Group>
const response = await uploadAvatar(file) <Form.Label>{t('nav.theme')}</Form.Label>
setAvatarPreview(response.url) <Form.Select value={theme} onChange={(event) => setTheme(event.target.value)}>
} catch (err) { <option value="auto">{t('ucp.system_default')}</option>
setAvatarError(err.message) <option value="dark">{t('nav.theme_dark')}</option>
} finally { <option value="light">{t('nav.theme_light')}</option>
setAvatarUploading(false) </Form.Select>
} </Form.Group>
}} </Col>
/> <Col md={6}>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text> <Form.Group>
</Form.Group> <Form.Label>{t('ucp.accent_override')}</Form.Label>
<Form.Group className="mt-3"> <div className="d-flex align-items-center gap-2">
<Form.Label>{t('ucp.location_label')}</Form.Label> <Form.Select
<Form.Control value={accentMode}
type="text" onChange={(event) => {
value={location} const mode = event.target.value
disabled={!token || profileSaving} if (mode === 'system') {
onChange={(event) => { setAccentOverride('')
setLocation(event.target.value) } else if (!accentOverride) {
setProfileSaved(false) setAccentOverride('#f29b3f')
}} }
/> }}
<Form.Text className="bb-muted">{t('ucp.location_hint')}</Form.Text> >
</Form.Group> <option value="system">{t('ucp.system_default')}</option>
{profileError && <p className="text-danger mt-2 mb-0">{profileError}</p>} <option value="custom">{t('ucp.custom_color')}</option>
{profileSaved && <p className="text-success mt-2 mb-0">{t('ucp.profile_saved')}</p>} </Form.Select>
<Button <Form.Control
type="button" type="color"
variant="outline-light" value={accentOverride || '#f29b3f'}
className="mt-3" onChange={(event) => setAccentOverride(event.target.value)}
disabled={!token || profileSaving} disabled={accentMode !== 'custom'}
onClick={async () => { />
setProfileError('') </div>
setProfileSaved(false) <Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
setProfileSaving(true) </Form.Group>
try { </Col>
const response = await updateCurrentUser({ location }) </Row>
setLocation(response?.location || '') </div>
setProfileSaved(true) </Container>
} 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-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
<Row className="g-3">
<Col xs={12}>
<Form.Group>
<Form.Label>{t('nav.language')}</Form.Label>
<Form.Select value={i18n.language} onChange={handleLanguageChange}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('nav.theme')}</Form.Label>
<Form.Select value={theme} onChange={(event) => setTheme(event.target.value)}>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('ucp.accent_override')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Select
value={accentMode}
onChange={(event) => {
const mode = event.target.value
if (mode === 'system') {
setAccentOverride('')
} else if (!accentOverride) {
setAccentOverride('#f29b3f')
}
}}
>
<option value="system">{t('ucp.system_default')}</option>
<option value="custom">{t('ucp.custom_color')}</option>
</Form.Select>
<Form.Control
type="color"
value={accentOverride || '#f29b3f'}
onChange={(event) => setAccentOverride(event.target.value)}
disabled={accentMode !== 'custom'}
/>
</div>
<Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
</Container>
)
} }

View File

@@ -45,6 +45,7 @@
"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.ranks": "Ränge",
"acp.groups": "Gruppen",
"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",
@@ -112,7 +113,11 @@
"rank.badge_type": "Badge-Typ", "rank.badge_type": "Badge-Typ",
"rank.badge_text": "Text-Badge", "rank.badge_text": "Text-Badge",
"rank.badge_image": "Bild-Badge", "rank.badge_image": "Bild-Badge",
"rank.badge_none": "Kein Badge",
"rank.badge_text_placeholder": "z. B. TEAM-RHF", "rank.badge_text_placeholder": "z. B. TEAM-RHF",
"rank.color": "Rangfarbe",
"rank.color_placeholder": "z. B. #f29b3f",
"rank.color_default": "Standardfarbe verwenden",
"rank.badge_text_required": "Badge-Text ist erforderlich.", "rank.badge_text_required": "Badge-Text ist erforderlich.",
"rank.badge_image_required": "Badge-Bild ist erforderlich.", "rank.badge_image_required": "Badge-Bild ist erforderlich.",
"rank.delete_confirm": "Diesen Rang löschen?", "rank.delete_confirm": "Diesen Rang löschen?",
@@ -122,6 +127,19 @@
"user.impersonate": "Imitieren", "user.impersonate": "Imitieren",
"user.edit": "Bearbeiten", "user.edit": "Bearbeiten",
"user.delete": "Löschen", "user.delete": "Löschen",
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
"group.create": "Gruppe erstellen",
"group.create_title": "Gruppe erstellen",
"group.edit_title": "Gruppe bearbeiten",
"group.name": "Gruppenname",
"group.name_placeholder": "z. B. Gründer",
"group.color": "Gruppenfarbe",
"group.color_placeholder": "z. B. #f29b3f",
"group.delete_confirm": "Diese Gruppe löschen?",
"group.empty": "Noch keine Gruppen vorhanden.",
"group.edit": "Bearbeiten",
"group.delete": "Löschen",
"group.core_locked": "Kern-Gruppen können nicht geändert werden.",
"table.rows_per_page": "Zeilen pro Seite:", "table.rows_per_page": "Zeilen pro Seite:",
"table.range_separator": "von", "table.range_separator": "von",
"home.browse": "Foren durchsuchen", "home.browse": "Foren durchsuchen",
@@ -175,6 +193,11 @@
"profile.title": "Profil", "profile.title": "Profil",
"profile.loading": "Profil wird geladen...", "profile.loading": "Profil wird geladen...",
"profile.registered": "Registriert:", "profile.registered": "Registriert:",
"profile.thanks_given": "Hat sich bedankt",
"profile.thanks_received": "Dank erhalten",
"profile.thanks_empty": "Noch keine Danksagungen.",
"profile.thanks_by": "Dank von",
"profile.thanks_for": "Dank für",
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.", "ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
"ucp.profile": "Profil", "ucp.profile": "Profil",
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.", "ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
@@ -197,6 +220,9 @@
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.", "thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
"thread.posts": "Beiträge", "thread.posts": "Beiträge",
"thread.location": "Wohnort", "thread.location": "Wohnort",
"thread.thanks_given": "Hat sich bedankt",
"thread.thanks_received": "Dank erhalten",
"thread.thanks": "Danke",
"thread.reply_prefix": "Aw:", "thread.reply_prefix": "Aw:",
"thread.registered": "Registriert", "thread.registered": "Registriert",
"thread.replies": "Antworten", "thread.replies": "Antworten",

View File

@@ -45,6 +45,7 @@
"acp.add_category": "Add category", "acp.add_category": "Add category",
"acp.add_forum": "Add forum", "acp.add_forum": "Add forum",
"acp.ranks": "Ranks", "acp.ranks": "Ranks",
"acp.groups": "Groups",
"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",
@@ -112,7 +113,11 @@
"rank.badge_type": "Badge type", "rank.badge_type": "Badge type",
"rank.badge_text": "Text badge", "rank.badge_text": "Text badge",
"rank.badge_image": "Image badge", "rank.badge_image": "Image badge",
"rank.badge_none": "No badge",
"rank.badge_text_placeholder": "e.g. TEAM-RHF", "rank.badge_text_placeholder": "e.g. TEAM-RHF",
"rank.color": "Rank color",
"rank.color_placeholder": "e.g. #f29b3f",
"rank.color_default": "Use default color",
"rank.badge_text_required": "Badge text is required.", "rank.badge_text_required": "Badge text is required.",
"rank.badge_image_required": "Badge image is required.", "rank.badge_image_required": "Badge image is required.",
"rank.delete_confirm": "Delete this rank?", "rank.delete_confirm": "Delete this rank?",
@@ -122,6 +127,19 @@
"user.impersonate": "Impersonate", "user.impersonate": "Impersonate",
"user.edit": "Edit", "user.edit": "Edit",
"user.delete": "Delete", "user.delete": "Delete",
"user.founder_locked": "Only founders can edit or assign the Founder role.",
"group.create": "Create group",
"group.create_title": "Create group",
"group.edit_title": "Edit group",
"group.name": "Group name",
"group.name_placeholder": "e.g. Founder",
"group.color": "Group color",
"group.color_placeholder": "e.g. #f29b3f",
"group.delete_confirm": "Delete this group?",
"group.empty": "No groups created yet.",
"group.edit": "Edit",
"group.delete": "Delete",
"group.core_locked": "Core groups cannot be changed.",
"table.rows_per_page": "Rows per page:", "table.rows_per_page": "Rows per page:",
"table.range_separator": "of", "table.range_separator": "of",
"home.browse": "Browse forums", "home.browse": "Browse forums",
@@ -175,6 +193,11 @@
"profile.title": "Profile", "profile.title": "Profile",
"profile.loading": "Loading profile...", "profile.loading": "Loading profile...",
"profile.registered": "Registered:", "profile.registered": "Registered:",
"profile.thanks_given": "Thanks given",
"profile.thanks_received": "Thanks received",
"profile.thanks_empty": "No thanks yet.",
"profile.thanks_by": "Thanks by",
"profile.thanks_for": "Thanks for",
"ucp.intro": "Manage your basic preferences for the forum.", "ucp.intro": "Manage your basic preferences for the forum.",
"ucp.profile": "Profile", "ucp.profile": "Profile",
"ucp.profile_hint": "Update the avatar shown next to your posts.", "ucp.profile_hint": "Update the avatar shown next to your posts.",
@@ -197,6 +220,9 @@
"thread.login_hint": "Log in to reply to this thread.", "thread.login_hint": "Log in to reply to this thread.",
"thread.posts": "Posts", "thread.posts": "Posts",
"thread.location": "Location", "thread.location": "Location",
"thread.thanks_given": "Thanks given",
"thread.thanks_received": "Thanks received",
"thread.thanks": "Thank",
"thread.reply_prefix": "Re:", "thread.reply_prefix": "Re:",
"thread.registered": "Registered", "thread.registered": "Registered",
"thread.replies": "Replies", "thread.replies": "Replies",

View File

@@ -8,6 +8,7 @@
@vite(['resources/js/main.jsx']) @vite(['resources/js/main.jsx'])
</head> </head>
<body> <body>
<div id="top"></div>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>speedBB Installed</title>
<style>
:root {
color-scheme: dark;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: #0b0f17;
color: #e6e8eb;
}
body {
margin: 0;
padding: 2rem 1rem;
display: flex;
justify-content: center;
}
.card {
width: 100%;
max-width: 560px;
background: rgba(18, 23, 33, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
text-align: center;
}
h1 {
margin: 0 0 0.5rem;
}
p {
margin: 0 0 1.5rem;
color: #9aa4b2;
}
a {
display: inline-flex;
padding: 0.7rem 1.5rem;
border-radius: 12px;
background: #ff8a3d;
color: #1a1a1a;
font-weight: 700;
text-decoration: none;
}
</style>
</head>
<body>
<div class="card">
<h1>Installation complete</h1>
<p>Your forum is ready. You can now log in with the founder account.</p>
<a href="/">Go to forum</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>speedBB Installer</title>
<style>
:root {
color-scheme: dark;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: #0b0f17;
color: #e6e8eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 2rem 1rem 4rem;
display: flex;
justify-content: center;
}
.card {
width: 100%;
max-width: 720px;
background: rgba(18, 23, 33, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.8rem;
}
p {
margin: 0 0 1.5rem;
color: #9aa4b2;
}
.section {
margin-bottom: 2rem;
}
label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
}
input {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(12, 16, 24, 0.9);
color: #e6e8eb;
min-width: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.grid--wide {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.error {
background: rgba(220, 80, 80, 0.15);
border: 1px solid rgba(220, 80, 80, 0.4);
color: #ffb4b4;
padding: 0.75rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
button {
padding: 0.7rem 1.5rem;
border-radius: 12px;
border: none;
background: #ff8a3d;
color: #1a1a1a;
font-weight: 700;
cursor: pointer;
}
</style>
</head>
<body>
<div class="card">
<h1>speedBB Installer</h1>
<p>Provide database details and create the first founder/admin account.</p>
@if (!empty($error))
<div class="error">{{ $error }}</div>
@endif
@if ($errors->any())
<div class="error">
<ul>
@foreach ($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="/install">
@csrf
<div class="section">
<h2>Application</h2>
<label for="app_url">App URL</label>
<input id="app_url" name="app_url" type="url" required value="{{ old('app_url', $old['app_url'] ?? $appUrl) }}" />
</div>
<div class="section">
<h2>Database (MySQL/MariaDB)</h2>
<div class="grid">
<div>
<label for="db_host">Host</label>
<input id="db_host" name="db_host" required value="{{ old('db_host', $old['db_host'] ?? '127.0.0.1') }}" />
</div>
<div>
<label for="db_port">Port</label>
<input id="db_port" name="db_port" type="number" value="{{ old('db_port', $old['db_port'] ?? 3306) }}" />
</div>
<div>
<label for="db_database">Database</label>
<input id="db_database" name="db_database" required value="{{ old('db_database', $old['db_database'] ?? '') }}" />
</div>
<div>
<label for="db_username">Username</label>
<input id="db_username" name="db_username" required value="{{ old('db_username', $old['db_username'] ?? '') }}" />
</div>
<div>
<label for="db_password">Password</label>
<input id="db_password" name="db_password" type="password" value="{{ old('db_password', $old['db_password'] ?? '') }}" />
</div>
</div>
</div>
<div class="section">
<h2>Founder Account</h2>
<div class="grid grid--wide">
<div>
<label for="admin_name">Username</label>
<input id="admin_name" name="admin_name" required value="{{ old('admin_name', $old['admin_name'] ?? '') }}" />
</div>
<div>
<label for="admin_email">Email</label>
<input id="admin_email" name="admin_email" type="email" required value="{{ old('admin_email', $old['admin_email'] ?? '') }}" />
</div>
<div>
<label for="admin_password">Password</label>
<input id="admin_password" name="admin_password" type="password" required />
</div>
</div>
</div>
<div class="actions">
<button type="submit">Install speedBB</button>
</div>
</form>
</div>
</body>
</html>

View File

@@ -5,6 +5,7 @@ use App\Http\Controllers\ForumController;
use App\Http\Controllers\I18nController; use App\Http\Controllers\I18nController;
use App\Http\Controllers\PortalController; use App\Http\Controllers\PortalController;
use App\Http\Controllers\PostController; use App\Http\Controllers\PostController;
use App\Http\Controllers\PostThankController;
use App\Http\Controllers\SettingController; use App\Http\Controllers\SettingController;
use App\Http\Controllers\StatsController; use App\Http\Controllers\StatsController;
use App\Http\Controllers\ThreadController; use App\Http\Controllers\ThreadController;
@@ -13,6 +14,7 @@ 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 App\Http\Controllers\RankController;
use App\Http\Controllers\RoleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
@@ -43,6 +45,12 @@ Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum'
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum'); Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum'); Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum'); Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
Route::get('/roles', [RoleController::class, 'index'])->middleware('auth:sanctum');
Route::post('/roles', [RoleController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/roles/{role}', [RoleController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/user/{user}/thanks/given', [PostThankController::class, 'given'])->middleware('auth:sanctum');
Route::get('/user/{user}/thanks/received', [PostThankController::class, 'received'])->middleware('auth:sanctum');
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum'); Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum'); Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum'); Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum');
@@ -63,4 +71,6 @@ Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middle
Route::get('/posts', [PostController::class, 'index']); Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum'); Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum'); Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');

View File

@@ -1,9 +1,37 @@
<?php <?php
use App\Http\Controllers\InstallerController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::view('/', 'app'); Route::get('/install', [InstallerController::class, 'show']);
Route::view('/login', 'app')->name('login'); Route::post('/install', [InstallerController::class, 'store'])
Route::view('/reset-password', 'app')->name('password.reset'); ->withoutMiddleware([VerifyCsrfToken::class]);
Route::view('/{any}', 'app')->where('any', '^(?!api).*$'); Route::get('/', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
});
Route::get('/login', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
})->name('login');
Route::get('/reset-password', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
})->name('password.reset');
Route::get('/{any}', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
})->where('any', '^(?!api).*$');