feat: add installer, ranks/groups enhancements, and founder protections
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
app/Http/Controllers/InstallerController.php
Normal file
140
app/Http/Controllers/InstallerController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
app/Http/Controllers/PostThankController.php
Normal file
122
app/Http/Controllers/PostThankController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
141
app/Http/Controllers/RoleController.php
Normal file
141
app/Http/Controllers/RoleController.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
24
app/Models/PostThank.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class Role extends Model
|
|||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'color',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function users(): BelongsToMany
|
public function users(): BelongsToMany
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -218,11 +218,11 @@ function PortalHeader({
|
|||||||
<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" />}
|
||||||
@@ -426,7 +426,7 @@ function AppShell() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bb-shell">
|
<div className="bb-shell" id="top">
|
||||||
<PortalHeader
|
<PortalHeader
|
||||||
isAuthenticated={!!token}
|
isAuthenticated={!!token}
|
||||||
forumName={settings.forumName}
|
forumName={settings.forumName}
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ 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')
|
||||||
}
|
}
|
||||||
@@ -250,6 +258,30 @@ 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',
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
|||||||
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 '—'
|
||||||
@@ -34,7 +42,11 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
|||||||
<div className="bb-portal-topic-meta-line">
|
<div className="bb-portal-topic-meta-line">
|
||||||
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
||||||
{thread.user_id ? (
|
{thread.user_id ? (
|
||||||
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
|
<Link
|
||||||
|
to={`/profile/${thread.user_id}`}
|
||||||
|
className="bb-portal-topic-author"
|
||||||
|
style={authorLinkStyle}
|
||||||
|
>
|
||||||
{authorName}
|
{authorName}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -67,7 +79,11 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
|||||||
<span className="bb-portal-last-by">
|
<span className="bb-portal-last-by">
|
||||||
{t('thread.by')}{' '}
|
{t('thread.by')}{' '}
|
||||||
{thread.last_post_user_id ? (
|
{thread.last_post_user_id ? (
|
||||||
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
|
<Link
|
||||||
|
to={`/profile/${thread.last_post_user_id}`}
|
||||||
|
className="bb-portal-last-user"
|
||||||
|
style={lastAuthorLinkStyle}
|
||||||
|
>
|
||||||
{lastAuthorName}
|
{lastAuthorName}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab,
|
|||||||
import DataTable, { createTheme } from 'react-data-table-component'
|
import DataTable, { createTheme } from 'react-data-table-component'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import {
|
import {
|
||||||
createForum,
|
createForum,
|
||||||
deleteForum,
|
deleteForum,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
listAllForums,
|
listAllForums,
|
||||||
listRanks,
|
listRanks,
|
||||||
|
listRoles,
|
||||||
listUsers,
|
listUsers,
|
||||||
reorderForums,
|
reorderForums,
|
||||||
saveSetting,
|
saveSetting,
|
||||||
@@ -18,6 +20,9 @@ import {
|
|||||||
updateUserRank,
|
updateUserRank,
|
||||||
updateRank,
|
updateRank,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
createRole,
|
||||||
|
updateRole,
|
||||||
|
deleteRole,
|
||||||
uploadRankBadgeImage,
|
uploadRankBadgeImage,
|
||||||
uploadFavicon,
|
uploadFavicon,
|
||||||
uploadLogo,
|
uploadLogo,
|
||||||
@@ -26,6 +31,8 @@ import {
|
|||||||
|
|
||||||
export default function Acp({ isAdmin }) {
|
export default function Acp({ isAdmin }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { roles: authRoles } = useAuth()
|
||||||
|
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
|
||||||
const [forums, setForums] = useState([])
|
const [forums, setForums] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -48,8 +55,10 @@ export default function Acp({ isAdmin }) {
|
|||||||
const [rankFormName, setRankFormName] = useState('')
|
const [rankFormName, setRankFormName] = useState('')
|
||||||
const [rankFormType, setRankFormType] = useState('text')
|
const [rankFormType, setRankFormType] = useState('text')
|
||||||
const [rankFormText, setRankFormText] = useState('')
|
const [rankFormText, setRankFormText] = useState('')
|
||||||
|
const [rankFormColor, setRankFormColor] = useState('')
|
||||||
const [rankFormImage, setRankFormImage] = useState(null)
|
const [rankFormImage, setRankFormImage] = useState(null)
|
||||||
const [rankSaving, setRankSaving] = useState(false)
|
const [rankSaving, setRankSaving] = useState(false)
|
||||||
|
const [showRankCreate, setShowRankCreate] = useState(false)
|
||||||
const [showRankModal, setShowRankModal] = useState(false)
|
const [showRankModal, setShowRankModal] = useState(false)
|
||||||
const [rankEdit, setRankEdit] = useState({
|
const [rankEdit, setRankEdit] = useState({
|
||||||
id: null,
|
id: null,
|
||||||
@@ -57,10 +66,29 @@ export default function Acp({ isAdmin }) {
|
|||||||
badgeType: 'text',
|
badgeType: 'text',
|
||||||
badgeText: '',
|
badgeText: '',
|
||||||
badgeImageUrl: '',
|
badgeImageUrl: '',
|
||||||
|
color: '',
|
||||||
})
|
})
|
||||||
const [rankEditImage, setRankEditImage] = useState(null)
|
const [rankEditImage, setRankEditImage] = useState(null)
|
||||||
const [showUserModal, setShowUserModal] = useState(false)
|
const [showUserModal, setShowUserModal] = useState(false)
|
||||||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
|
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
|
||||||
|
const [roleQuery, setRoleQuery] = useState('')
|
||||||
|
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
|
||||||
|
const roleMenuRef = useRef(null)
|
||||||
|
const [roles, setRoles] = useState([])
|
||||||
|
const [rolesLoading, setRolesLoading] = useState(false)
|
||||||
|
const [rolesError, setRolesError] = useState('')
|
||||||
|
const [roleFormName, setRoleFormName] = useState('')
|
||||||
|
const [roleFormColor, setRoleFormColor] = useState('')
|
||||||
|
const [roleSaving, setRoleSaving] = useState(false)
|
||||||
|
const [showRoleCreate, setShowRoleCreate] = useState(false)
|
||||||
|
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||||
|
const [roleEdit, setRoleEdit] = useState({
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
originalName: '',
|
||||||
|
color: '',
|
||||||
|
isCore: false,
|
||||||
|
})
|
||||||
const [userSaving, setUserSaving] = useState(false)
|
const [userSaving, setUserSaving] = useState(false)
|
||||||
const [generalSaving, setGeneralSaving] = useState(false)
|
const [generalSaving, setGeneralSaving] = useState(false)
|
||||||
const [generalUploading, setGeneralUploading] = useState(false)
|
const [generalUploading, setGeneralUploading] = useState(false)
|
||||||
@@ -473,15 +501,22 @@ export default function Acp({ isAdmin }) {
|
|||||||
>
|
>
|
||||||
<i className="bi bi-person-badge" aria-hidden="true" />
|
<i className="bi bi-person-badge" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{(() => {
|
||||||
|
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
title={t('user.edit')}
|
title={editLocked ? t('user.founder_locked') : t('user.edit')}
|
||||||
|
aria-disabled={editLocked}
|
||||||
|
className={editLocked ? 'bb-btn-disabled' : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (editLocked) return
|
||||||
setUserForm({
|
setUserForm({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
rankId: row.rank?.id ?? '',
|
rankId: row.rank?.id ?? '',
|
||||||
|
roles: row.roles || [],
|
||||||
})
|
})
|
||||||
setShowUserModal(true)
|
setShowUserModal(true)
|
||||||
setUsersError('')
|
setUsersError('')
|
||||||
@@ -489,6 +524,8 @@ export default function Acp({ isAdmin }) {
|
|||||||
>
|
>
|
||||||
<i className="bi bi-pencil" aria-hidden="true" />
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
title={t('user.delete')}
|
title={t('user.delete')}
|
||||||
@@ -652,6 +689,39 @@ export default function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}, [isAdmin])
|
}, [isAdmin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roleMenuOpen) return
|
||||||
|
const handleClick = (event) => {
|
||||||
|
if (!roleMenuRef.current) return
|
||||||
|
if (!roleMenuRef.current.contains(event.target)) {
|
||||||
|
setRoleMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [roleMenuOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return
|
||||||
|
let active = true
|
||||||
|
const loadRoles = async () => {
|
||||||
|
setRolesLoading(true)
|
||||||
|
setRolesError('')
|
||||||
|
try {
|
||||||
|
const data = await listRoles()
|
||||||
|
if (active) setRoles(data)
|
||||||
|
} catch (err) {
|
||||||
|
if (active) setRolesError(err.message)
|
||||||
|
} finally {
|
||||||
|
if (active) setRolesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadRoles()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [isAdmin])
|
||||||
|
|
||||||
const refreshRanks = async () => {
|
const refreshRanks = async () => {
|
||||||
setRanksLoading(true)
|
setRanksLoading(true)
|
||||||
setRanksError('')
|
setRanksError('')
|
||||||
@@ -685,6 +755,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
name: rankFormName.trim(),
|
name: rankFormName.trim(),
|
||||||
badge_type: rankFormType,
|
badge_type: rankFormType,
|
||||||
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
||||||
|
color: rankFormColor.trim() || null,
|
||||||
})
|
})
|
||||||
let next = created
|
let next = created
|
||||||
if (rankFormType === 'image' && rankFormImage) {
|
if (rankFormType === 'image' && rankFormImage) {
|
||||||
@@ -695,6 +766,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
setRankFormName('')
|
setRankFormName('')
|
||||||
setRankFormType('text')
|
setRankFormType('text')
|
||||||
setRankFormText('')
|
setRankFormText('')
|
||||||
|
setRankFormColor('')
|
||||||
setRankFormImage(null)
|
setRankFormImage(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRanksError(err.message)
|
setRanksError(err.message)
|
||||||
@@ -703,6 +775,46 @@ export default function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCoreRole = (name) => name === 'ROLE_ADMIN' || name === 'ROLE_USER' || name === 'ROLE_FOUNDER'
|
||||||
|
|
||||||
|
const formatRoleLabel = (name) => {
|
||||||
|
if (!name) return ''
|
||||||
|
const withoutPrefix = name.startsWith('ROLE_') ? name.slice(5) : name
|
||||||
|
return withoutPrefix
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRoles = useMemo(() => {
|
||||||
|
const query = roleQuery.trim().toLowerCase()
|
||||||
|
if (!query) return roles
|
||||||
|
return roles.filter((role) =>
|
||||||
|
formatRoleLabel(role.name).toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}, [roles, roleQuery])
|
||||||
|
|
||||||
|
const handleCreateRole = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!roleFormName.trim()) return
|
||||||
|
setRoleSaving(true)
|
||||||
|
setRolesError('')
|
||||||
|
try {
|
||||||
|
const created = await createRole({
|
||||||
|
name: roleFormName.trim(),
|
||||||
|
color: roleFormColor.trim() || null,
|
||||||
|
})
|
||||||
|
setRoles((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
setRoleFormName('')
|
||||||
|
setRoleFormColor('')
|
||||||
|
setShowRoleCreate(false)
|
||||||
|
} catch (err) {
|
||||||
|
setRolesError(err.message)
|
||||||
|
} finally {
|
||||||
|
setRoleSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getParentId = (forum) => {
|
const getParentId = (forum) => {
|
||||||
if (!forum.parent) return null
|
if (!forum.parent) return null
|
||||||
if (typeof forum.parent === 'string') {
|
if (typeof forum.parent === 'string') {
|
||||||
@@ -1505,76 +1617,108 @@ export default function Acp({ isAdmin }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
<Tab eventKey="groups" title={t('acp.groups')}>
|
||||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||||
<Row className="g-3 align-items-end mb-3">
|
<div className="d-flex justify-content-end mb-3">
|
||||||
<Col md={6}>
|
|
||||||
<Form onSubmit={handleCreateRank}>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>{t('rank.name')}</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
value={rankFormName}
|
|
||||||
onChange={(event) => setRankFormName(event.target.value)}
|
|
||||||
placeholder={t('rank.name_placeholder')}
|
|
||||||
disabled={rankSaving}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
<Form.Group className="mt-3">
|
|
||||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
|
||||||
<div className="d-flex gap-3">
|
|
||||||
<Form.Check
|
|
||||||
type="radio"
|
|
||||||
id="rank-badge-text"
|
|
||||||
name="rankBadgeType"
|
|
||||||
label={t('rank.badge_text')}
|
|
||||||
checked={rankFormType === 'text'}
|
|
||||||
onChange={() => setRankFormType('text')}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
type="radio"
|
|
||||||
id="rank-badge-image"
|
|
||||||
name="rankBadgeType"
|
|
||||||
label={t('rank.badge_image')}
|
|
||||||
checked={rankFormType === 'image'}
|
|
||||||
onChange={() => setRankFormType('image')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Form.Group>
|
|
||||||
{rankFormType === 'text' && (
|
|
||||||
<Form.Group className="mt-3">
|
|
||||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
value={rankFormText}
|
|
||||||
onChange={(event) => setRankFormText(event.target.value)}
|
|
||||||
placeholder={t('rank.badge_text_placeholder')}
|
|
||||||
disabled={rankSaving}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
)}
|
|
||||||
{rankFormType === 'image' && (
|
|
||||||
<Form.Group className="mt-3">
|
|
||||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
|
||||||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
|
||||||
disabled={rankSaving}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Col>
|
|
||||||
<Col md="auto">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="bb-accent-button"
|
className="bb-accent-button"
|
||||||
onClick={handleCreateRank}
|
onClick={() => setShowRoleCreate(true)}
|
||||||
disabled={rankSaving || !rankFormName.trim()}
|
|
||||||
>
|
>
|
||||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
{t('group.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
{rolesLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||||
|
{!rolesLoading && roles.length === 0 && (
|
||||||
|
<p className="bb-muted">{t('group.empty')}</p>
|
||||||
|
)}
|
||||||
|
{!rolesLoading && roles.length > 0 && (
|
||||||
|
<div className="bb-rank-list">
|
||||||
|
{roles.map((role) => {
|
||||||
|
const coreRole = isCoreRole(role.name)
|
||||||
|
return (
|
||||||
|
<div key={role.id} className="bb-rank-row">
|
||||||
|
<div className="bb-rank-main">
|
||||||
|
<span className="d-flex align-items-center gap-2">
|
||||||
|
{role.color && (
|
||||||
|
<span
|
||||||
|
className="bb-rank-color"
|
||||||
|
style={{ backgroundColor: role.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{formatRoleLabel(role.name)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-rank-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="dark"
|
||||||
|
title={coreRole ? t('group.core_locked') : t('group.edit')}
|
||||||
|
onClick={() => {
|
||||||
|
setRoleEdit({
|
||||||
|
id: role.id,
|
||||||
|
name: formatRoleLabel(role.name),
|
||||||
|
originalName: role.name,
|
||||||
|
color: role.color || '',
|
||||||
|
isCore: coreRole,
|
||||||
|
})
|
||||||
|
setShowRoleModal(true)
|
||||||
|
setRolesError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="dark"
|
||||||
|
disabled={coreRole}
|
||||||
|
title={coreRole ? t('group.core_locked') : t('group.delete')}
|
||||||
|
onClick={async () => {
|
||||||
|
if (coreRole) return
|
||||||
|
if (!window.confirm(t('group.delete_confirm'))) return
|
||||||
|
setRoleSaving(true)
|
||||||
|
setRolesError('')
|
||||||
|
try {
|
||||||
|
await deleteRole(role.id)
|
||||||
|
setRoles((prev) =>
|
||||||
|
prev.filter((item) => item.id !== role.id)
|
||||||
|
)
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((user) => ({
|
||||||
|
...user,
|
||||||
|
roles: (user.roles || []).filter(
|
||||||
|
(name) => name !== role.name
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setRolesError(err.message)
|
||||||
|
} finally {
|
||||||
|
setRoleSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
|
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||||
|
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||||
|
<div className="d-flex justify-content-end mb-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bb-accent-button"
|
||||||
|
onClick={() => setShowRankCreate(true)}
|
||||||
|
>
|
||||||
|
{t('rank.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||||
{!ranksLoading && ranks.length === 0 && (
|
{!ranksLoading && ranks.length === 0 && (
|
||||||
<p className="bb-muted">{t('rank.empty')}</p>
|
<p className="bb-muted">{t('rank.empty')}</p>
|
||||||
@@ -1584,15 +1728,24 @@ export default function Acp({ isAdmin }) {
|
|||||||
{ranks.map((rank) => (
|
{ranks.map((rank) => (
|
||||||
<div key={rank.id} className="bb-rank-row">
|
<div key={rank.id} className="bb-rank-row">
|
||||||
<div className="bb-rank-main">
|
<div className="bb-rank-main">
|
||||||
|
<span className="d-flex align-items-center gap-2">
|
||||||
|
{rank.color && (
|
||||||
|
<span
|
||||||
|
className="bb-rank-color"
|
||||||
|
style={{ backgroundColor: rank.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span>{rank.name}</span>
|
<span>{rank.name}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-rank-actions">
|
||||||
{rank.badge_type === 'image' && rank.badge_image_url && (
|
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||||||
<img src={rank.badge_image_url} alt="" />
|
<img src={rank.badge_image_url} alt="" />
|
||||||
)}
|
)}
|
||||||
{rank.badge_type !== 'image' && rank.badge_text && (
|
{rank.badge_type !== 'image' && rank.badge_text && (
|
||||||
<span className="bb-rank-badge">{rank.badge_text}</span>
|
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div className="bb-rank-actions">
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="dark"
|
variant="dark"
|
||||||
@@ -1603,6 +1756,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
badgeType: rank.badge_type || 'text',
|
badgeType: rank.badge_type || 'text',
|
||||||
badgeText: rank.badge_text || '',
|
badgeText: rank.badge_text || '',
|
||||||
badgeImageUrl: rank.badge_image_url || '',
|
badgeImageUrl: rank.badge_image_url || '',
|
||||||
|
color: rank.color || '',
|
||||||
})
|
})
|
||||||
setRankEditImage(null)
|
setRankEditImage(null)
|
||||||
setShowRankModal(true)
|
setShowRankModal(true)
|
||||||
@@ -1736,6 +1890,10 @@ export default function Acp({ isAdmin }) {
|
|||||||
<Form
|
<Form
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder) {
|
||||||
|
setUsersError(t('user.founder_locked'))
|
||||||
|
return
|
||||||
|
}
|
||||||
setUserSaving(true)
|
setUserSaving(true)
|
||||||
setUsersError('')
|
setUsersError('')
|
||||||
try {
|
try {
|
||||||
@@ -1744,6 +1902,9 @@ export default function Acp({ isAdmin }) {
|
|||||||
email: userForm.email,
|
email: userForm.email,
|
||||||
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
||||||
}
|
}
|
||||||
|
if (roles.length) {
|
||||||
|
payload.roles = userForm.roles || []
|
||||||
|
}
|
||||||
const updated = await updateUser(userForm.id, payload)
|
const updated = await updateUser(userForm.id, payload)
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((user) =>
|
prev.map((user) =>
|
||||||
@@ -1758,6 +1919,9 @@ export default function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder && (
|
||||||
|
<p className="text-danger">{t('user.founder_locked')}</p>
|
||||||
|
)}
|
||||||
<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
|
||||||
@@ -1766,6 +1930,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
|
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
@@ -1777,6 +1942,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
|
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
@@ -1786,7 +1952,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
||||||
}
|
}
|
||||||
disabled={ranksLoading}
|
disabled={ranksLoading || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||||||
>
|
>
|
||||||
<option value="">{t('user.rank_unassigned')}</option>
|
<option value="">{t('user.rank_unassigned')}</option>
|
||||||
{ranks.map((rank) => (
|
{ranks.map((rank) => (
|
||||||
@@ -1796,6 +1962,120 @@ export default function Acp({ isAdmin }) {
|
|||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>{t('user.roles')}</Form.Label>
|
||||||
|
<div className="bb-multiselect" ref={roleMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-multiselect__control"
|
||||||
|
onClick={() => setRoleMenuOpen((prev) => !prev)}
|
||||||
|
disabled={!roles.length || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||||||
|
>
|
||||||
|
<span className="bb-multiselect__value">
|
||||||
|
{(userForm.roles || []).length === 0 && (
|
||||||
|
<span className="bb-multiselect__placeholder">
|
||||||
|
{t('user.roles')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(userForm.roles || []).map((roleName) => {
|
||||||
|
const role = roles.find((item) => item.name === roleName)
|
||||||
|
return (
|
||||||
|
<span key={roleName} className="bb-multiselect__chip">
|
||||||
|
{role?.color && (
|
||||||
|
<span
|
||||||
|
className="bb-multiselect__chip-color"
|
||||||
|
style={{ backgroundColor: role.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{formatRoleLabel(roleName)}
|
||||||
|
{!(roleName === 'ROLE_FOUNDER' && !canManageFounder) && (
|
||||||
|
<span
|
||||||
|
className="bb-multiselect__chip-remove"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setUserForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
roles: (prev.roles || []).filter(
|
||||||
|
(name) => name !== roleName
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="bb-multiselect__caret" aria-hidden="true">
|
||||||
|
<i className="bi bi-chevron-down" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{roleMenuOpen && (
|
||||||
|
<div className="bb-multiselect__menu">
|
||||||
|
<div className="bb-multiselect__search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={roleQuery}
|
||||||
|
onChange={(event) => setRoleQuery(event.target.value)}
|
||||||
|
placeholder={t('user.search')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bb-multiselect__options">
|
||||||
|
{filteredRoles.length === 0 && (
|
||||||
|
<div className="bb-multiselect__empty">
|
||||||
|
{t('rank.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredRoles.map((role) => {
|
||||||
|
const isSelected = (userForm.roles || []).includes(role.name)
|
||||||
|
const isFounderRole = role.name === 'ROLE_FOUNDER'
|
||||||
|
const isLocked = isFounderRole && !canManageFounder
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={role.id}
|
||||||
|
className={`bb-multiselect__option ${isSelected ? 'is-selected' : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
setUserForm((prev) => {
|
||||||
|
if (isLocked) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next = new Set(prev.roles || [])
|
||||||
|
if (next.has(role.name)) {
|
||||||
|
next.delete(role.name)
|
||||||
|
} else {
|
||||||
|
next.add(role.name)
|
||||||
|
}
|
||||||
|
return { ...prev, roles: Array.from(next) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isLocked}
|
||||||
|
>
|
||||||
|
<span className="bb-multiselect__option-main">
|
||||||
|
{role.color && (
|
||||||
|
<span
|
||||||
|
className="bb-multiselect__chip-color"
|
||||||
|
style={{ backgroundColor: role.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{formatRoleLabel(role.name)}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<i className="bi bi-check-lg" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
<div className="d-flex justify-content-end gap-2">
|
<div className="d-flex justify-content-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1812,6 +2092,145 @@ export default function Acp({ isAdmin }) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
show={showRoleModal}
|
||||||
|
onHide={() => setShowRoleModal(false)}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('group.edit_title')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||||
|
<Form
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!roleEdit.name.trim()) return
|
||||||
|
setRoleSaving(true)
|
||||||
|
setRolesError('')
|
||||||
|
try {
|
||||||
|
const updated = await updateRole(roleEdit.id, {
|
||||||
|
name: roleEdit.name.trim(),
|
||||||
|
color: roleEdit.color.trim() || null,
|
||||||
|
})
|
||||||
|
setRoles((prev) =>
|
||||||
|
prev
|
||||||
|
.map((item) => (item.id === updated.id ? updated : item))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
)
|
||||||
|
if (roleEdit.originalName && roleEdit.originalName !== updated.name) {
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((user) => ({
|
||||||
|
...user,
|
||||||
|
roles: (user.roles || []).map((name) =>
|
||||||
|
name === roleEdit.originalName ? updated.name : name
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setShowRoleModal(false)
|
||||||
|
} catch (err) {
|
||||||
|
setRolesError(err.message)
|
||||||
|
} finally {
|
||||||
|
setRoleSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>{t('group.name')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={roleEdit.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRoleEdit((prev) => ({ ...prev, name: event.target.value }))
|
||||||
|
}
|
||||||
|
disabled={roleEdit.isCore}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>{t('group.color')}</Form.Label>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
value={roleEdit.color}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t('group.color_placeholder')}
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
type="color"
|
||||||
|
value={roleEdit.color || '#f29b3f'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
<div className="d-flex justify-content-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => setShowRoleModal(false)}
|
||||||
|
disabled={roleSaving}
|
||||||
|
>
|
||||||
|
{t('acp.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="bb-accent-button" disabled={roleSaving}>
|
||||||
|
{roleSaving ? t('form.saving') : t('acp.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
show={showRoleCreate}
|
||||||
|
onHide={() => setShowRoleCreate(false)}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('group.create_title')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||||
|
<Form onSubmit={handleCreateRole}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('group.name')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={roleFormName}
|
||||||
|
onChange={(event) => setRoleFormName(event.target.value)}
|
||||||
|
placeholder={t('group.name_placeholder')}
|
||||||
|
disabled={roleSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('group.color')}</Form.Label>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
value={roleFormColor}
|
||||||
|
onChange={(event) => setRoleFormColor(event.target.value)}
|
||||||
|
placeholder={t('group.color_placeholder')}
|
||||||
|
disabled={roleSaving}
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
type="color"
|
||||||
|
value={roleFormColor || '#f29b3f'}
|
||||||
|
onChange={(event) => setRoleFormColor(event.target.value)}
|
||||||
|
disabled={roleSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
<div className="d-flex justify-content-end mt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={roleSaving || !roleFormName.trim()}
|
||||||
|
>
|
||||||
|
{roleSaving ? t('form.saving') : t('group.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
show={showRankModal}
|
show={showRankModal}
|
||||||
onHide={() => setShowRankModal(false)}
|
onHide={() => setShowRankModal(false)}
|
||||||
@@ -1840,6 +2259,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
rankEdit.badgeType === 'text'
|
rankEdit.badgeType === 'text'
|
||||||
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
||||||
: null,
|
: null,
|
||||||
|
color: rankEdit.color.trim() || null,
|
||||||
})
|
})
|
||||||
let next = updated
|
let next = updated
|
||||||
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
||||||
@@ -1869,9 +2289,54 @@ export default function Acp({ isAdmin }) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>{t('rank.color')}</Form.Label>
|
||||||
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="rank-edit-color-default"
|
||||||
|
label={t('rank.color_default')}
|
||||||
|
checked={!rankEdit.color}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRankEdit((prev) => ({
|
||||||
|
...prev,
|
||||||
|
color: event.target.checked ? '' : prev.color || '#f29b3f',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
value={rankEdit.color}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t('rank.color_placeholder')}
|
||||||
|
disabled={!rankEdit.color}
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
type="color"
|
||||||
|
value={rankEdit.color || '#f29b3f'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||||
|
}
|
||||||
|
disabled={!rankEdit.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||||
<div className="d-flex gap-3">
|
<div className="d-flex gap-3 flex-wrap">
|
||||||
|
<Form.Check
|
||||||
|
type="radio"
|
||||||
|
id="rank-edit-badge-none"
|
||||||
|
name="rankEditBadgeType"
|
||||||
|
label={t('rank.badge_none')}
|
||||||
|
checked={rankEdit.badgeType === 'none'}
|
||||||
|
onChange={() =>
|
||||||
|
setRankEdit((prev) => ({ ...prev, badgeType: 'none' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Form.Check
|
<Form.Check
|
||||||
type="radio"
|
type="radio"
|
||||||
id="rank-edit-badge-text"
|
id="rank-edit-badge-text"
|
||||||
@@ -1938,6 +2403,123 @@ export default function Acp({ isAdmin }) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
show={showRankCreate}
|
||||||
|
onHide={() => setShowRankCreate(false)}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('rank.create_title')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||||
|
<Form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleCreateRank()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('rank.name')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={rankFormName}
|
||||||
|
onChange={(event) => setRankFormName(event.target.value)}
|
||||||
|
placeholder={t('rank.name_placeholder')}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.color')}</Form.Label>
|
||||||
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="rank-create-color-default"
|
||||||
|
label={t('rank.color_default')}
|
||||||
|
checked={!rankFormColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRankFormColor(event.target.checked ? '' : '#f29b3f')
|
||||||
|
}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
value={rankFormColor}
|
||||||
|
onChange={(event) => setRankFormColor(event.target.value)}
|
||||||
|
placeholder={t('rank.color_placeholder')}
|
||||||
|
disabled={rankSaving || !rankFormColor}
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
type="color"
|
||||||
|
value={rankFormColor || '#f29b3f'}
|
||||||
|
onChange={(event) => setRankFormColor(event.target.value)}
|
||||||
|
disabled={rankSaving || !rankFormColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||||
|
<div className="d-flex gap-3 flex-wrap">
|
||||||
|
<Form.Check
|
||||||
|
type="radio"
|
||||||
|
id="rank-badge-none"
|
||||||
|
name="rankBadgeType"
|
||||||
|
label={t('rank.badge_none')}
|
||||||
|
checked={rankFormType === 'none'}
|
||||||
|
onChange={() => setRankFormType('none')}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="radio"
|
||||||
|
id="rank-badge-text"
|
||||||
|
name="rankBadgeType"
|
||||||
|
label={t('rank.badge_text')}
|
||||||
|
checked={rankFormType === 'text'}
|
||||||
|
onChange={() => setRankFormType('text')}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="radio"
|
||||||
|
id="rank-badge-image"
|
||||||
|
name="rankBadgeType"
|
||||||
|
label={t('rank.badge_image')}
|
||||||
|
checked={rankFormType === 'image'}
|
||||||
|
onChange={() => setRankFormType('image')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
{rankFormType === 'text' && (
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={rankFormText}
|
||||||
|
onChange={(event) => setRankFormText(event.target.value)}
|
||||||
|
placeholder={t('rank.badge_text_placeholder')}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
{rankFormType === 'image' && (
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||||
|
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
<div className="d-flex justify-content-end mt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={rankSaving || !rankFormName.trim()}
|
||||||
|
>
|
||||||
|
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,18 @@ export default function BoardIndex() {
|
|||||||
<span className="bb-board-last-by">
|
<span className="bb-board-last-by">
|
||||||
{t('thread.by')}{' '}
|
{t('thread.by')}{' '}
|
||||||
{node.last_post_user_id ? (
|
{node.last_post_user_id ? (
|
||||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
<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')}
|
{node.last_post_user_name || t('thread.anonymous')}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -44,7 +44,18 @@ export default function ForumView() {
|
|||||||
<span className="bb-board-last-by">
|
<span className="bb-board-last-by">
|
||||||
{t('thread.by')}{' '}
|
{t('thread.by')}{' '}
|
||||||
{node.last_post_user_id ? (
|
{node.last_post_user_id ? (
|
||||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
<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')}
|
{node.last_post_user_name || t('thread.anonymous')}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -226,7 +226,15 @@ export default function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="bb-portal-user-name">
|
<div className="bb-portal-user-name">
|
||||||
{profile?.id ? (
|
{profile?.id ? (
|
||||||
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
|
<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'}
|
{profile?.name || email || 'User'}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ 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()
|
||||||
@@ -10,23 +11,32 @@ export default function Profile() {
|
|||||||
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 : [])
|
||||||
|
setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
setThanksGiven([])
|
||||||
|
setThanksReceived([])
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (active) setLoading(false)
|
if (!active) return
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingThanks(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -34,6 +44,19 @@ export default function Profile() {
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = String(date.getFullYear())
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
||||||
@@ -59,6 +82,96 @@ export default function Profile() {
|
|||||||
</div>
|
</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>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
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('')
|
||||||
@@ -56,7 +56,7 @@ export default function ThreadView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyCount = posts.length
|
// const replyCount = posts.length
|
||||||
const formatDate = (value) => {
|
const formatDate = (value) => {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -72,11 +72,14 @@ export default function ThreadView() {
|
|||||||
id: `thread-${thread.id}`,
|
id: `thread-${thread.id}`,
|
||||||
body: thread.body,
|
body: thread.body,
|
||||||
created_at: thread.created_at,
|
created_at: thread.created_at,
|
||||||
|
user_id: thread.user_id,
|
||||||
user_name: thread.user_name,
|
user_name: thread.user_name,
|
||||||
user_avatar_url: thread.user_avatar_url,
|
user_avatar_url: thread.user_avatar_url,
|
||||||
user_posts_count: thread.user_posts_count,
|
user_posts_count: thread.user_posts_count,
|
||||||
user_created_at: thread.user_created_at,
|
user_created_at: thread.user_created_at,
|
||||||
user_location: thread.user_location,
|
user_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_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,
|
||||||
@@ -140,6 +143,17 @@ export default function ThreadView() {
|
|||||||
|| post.user_name
|
|| post.user_name
|
||||||
|| post.author_name
|
|| post.author_name
|
||||||
|| t('thread.anonymous')
|
|| 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
|
const topicLabel = thread?.title
|
||||||
? post.isRoot
|
? post.isRoot
|
||||||
? thread.title
|
? thread.title
|
||||||
@@ -190,12 +204,16 @@ export default function ThreadView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-stat">
|
<div className="bb-post-author-stat">
|
||||||
<span className="bb-post-author-label">Thanks given:</span>
|
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
|
||||||
<span className="bb-post-author-value">7</span>
|
<span className="bb-post-author-value">
|
||||||
|
{post.user_thanks_given_count ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-stat">
|
<div className="bb-post-author-stat">
|
||||||
<span className="bb-post-author-label">Thanks received:</span>
|
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
|
||||||
<span className="bb-post-author-value">5</span>
|
<span className="bb-post-author-value">
|
||||||
|
{post.user_thanks_received_count ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-stat bb-post-author-contact">
|
<div className="bb-post-author-stat bb-post-author-contact">
|
||||||
<span className="bb-post-author-label">Contact:</span>
|
<span className="bb-post-author-label">Contact:</span>
|
||||||
@@ -234,12 +252,21 @@ export default function ThreadView() {
|
|||||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||||
<i className="bi bi-quote" aria-hidden="true" />
|
<i className="bi bi-quote" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
|
{canThank && (
|
||||||
<i className="bi bi-house-door" aria-hidden="true" />
|
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
|
||||||
</a>
|
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-body">{post.body}</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
55
resources/views/installer-success.blade.php
Normal file
55
resources/views/installer-success.blade.php
Normal 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>
|
||||||
165
resources/views/installer.blade.php
Normal file
165
resources/views/installer.blade.php
Normal 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>
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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).*$');
|
||||||
|
|||||||
Reference in New Issue
Block a user