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/hot
|
||||
/public/storage
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*.php
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -14,31 +15,31 @@ class ForumController extends Controller
|
||||
{
|
||||
$query = Forum::query()
|
||||
->withoutTrashed()
|
||||
->withCount(['threads', 'posts'])
|
||||
->withSum('threads', 'views_count');
|
||||
->withCount(relations: ['threads', 'posts'])
|
||||
->withSum(relation: 'threads', column: 'views_count');
|
||||
|
||||
$parentParam = $request->query('parent');
|
||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$parentParam = $request->query(key: 'parent');
|
||||
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||
if ($exists === false) {
|
||||
$query->whereNull('parent_id');
|
||||
$query->whereNull(columns: 'parent_id');
|
||||
} elseif ($exists === true) {
|
||||
$query->whereNotNull('parent_id');
|
||||
$query->whereNotNull(columns: 'parent_id');
|
||||
}
|
||||
} elseif (is_string($parentParam)) {
|
||||
$parentId = $this->parseIriId($parentParam);
|
||||
} elseif (is_string(value: $parentParam)) {
|
||||
$parentId = $this->parseIriId(value: $parentParam);
|
||||
if ($parentId !== null) {
|
||||
$query->where('parent_id', $parentId);
|
||||
$query->where(column: 'parent_id', operator: $parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', $request->query('type'));
|
||||
if ($request->filled(key: 'type')) {
|
||||
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||
}
|
||||
|
||||
$forums = $query
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->orderBy(column: 'position')
|
||||
->orderBy(column: 'name')
|
||||
->get();
|
||||
|
||||
$forumIds = $forums->pluck('id')->all();
|
||||
@@ -216,6 +217,8 @@ class ForumController extends Controller
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -234,7 +237,7 @@ class ForumController extends Controller
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->with(['user.rank', 'user.roles'])
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
@@ -256,8 +259,28 @@ class ForumController extends Controller
|
||||
->where('threads.forum_id', $forumId)
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->orderByDesc(column: 'posts.created_at')
|
||||
->with(relations: ['user.rank', 'user.roles'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy(callback: 'name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
->withCount('posts')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query->withCount('posts')->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])
|
||||
->latest('created_at')
|
||||
->limit(12)
|
||||
@@ -62,7 +63,9 @@ class PortalController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
@@ -82,6 +85,8 @@ class PortalController extends Controller
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -109,12 +114,18 @@ class PortalController extends Controller
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -133,7 +144,7 @@ class PortalController extends Controller
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->with(['user.rank', 'user.roles'])
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
@@ -146,4 +157,24 @@ class PortalController extends Controller
|
||||
|
||||
return $byForum;
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ class PostController extends Controller
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Post::query()->withoutTrashed()->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
@@ -49,7 +51,9 @@ class PostController extends Controller
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
@@ -95,14 +99,38 @@ class PostController extends Controller
|
||||
'user_posts_count' => $post->user?->posts_count,
|
||||
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
||||
'user_location' => $post->user?->location,
|
||||
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
|
||||
'user_rank_name' => $post->user?->rank?->name,
|
||||
'user_rank_badge_type' => $post->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $post->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
|
||||
? Storage::url($post->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $post->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(data: ['message' => 'Forbidden'], status: 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -29,6 +29,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
@@ -45,19 +46,24 @@ class RankController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
|
||||
$rank = Rank::create([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $data['color'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -65,6 +71,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => null,
|
||||
], 201);
|
||||
}
|
||||
@@ -77,16 +84,21 @@ class RankController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
|
||||
|
||||
if ($badgeType === 'text' && $rank->badge_image_path) {
|
||||
if ($badgeType !== 'image' && $rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
$rank->badge_image_path = null;
|
||||
}
|
||||
@@ -95,6 +107,7 @@ class RankController extends Controller
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $color,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -102,6 +115,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
|
||||
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 Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
@@ -15,9 +16,13 @@ class ThreadController extends Controller
|
||||
$query = Thread::query()
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->withMax('posts', 'created_at')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
]);
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
@@ -29,7 +34,7 @@ class ThreadController extends Controller
|
||||
}
|
||||
|
||||
$threads = $query
|
||||
->latest('created_at')
|
||||
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
||||
->get()
|
||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||
|
||||
@@ -41,8 +46,11 @@ class ThreadController extends Controller
|
||||
$thread->increment('views_count');
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
@@ -70,8 +78,11 @@ class ThreadController extends Controller
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread), 201);
|
||||
@@ -120,20 +131,48 @@ class ThreadController extends Controller
|
||||
'user_posts_count' => $thread->user?->posts_count,
|
||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||
'user_location' => $thread->user?->location,
|
||||
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
|
||||
'user_rank_name' => $thread->user?->rank?->name,
|
||||
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,7 +27,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
]);
|
||||
|
||||
@@ -50,7 +53,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -65,7 +70,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'created_at' => $user->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
@@ -101,7 +108,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -112,6 +121,9 @@ class UserController extends Controller
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
@@ -127,7 +139,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -137,6 +151,9 @@ class UserController extends Controller
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
@@ -148,8 +165,18 @@ class UserController extends Controller
|
||||
Rule::unique('users', 'email')->ignore($user->id),
|
||||
],
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
'roles' => ['nullable', 'array'],
|
||||
'roles.*' => ['string', 'exists:roles,name'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('roles', $data) && !$this->isFounder($actor)) {
|
||||
$requested = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name));
|
||||
if ($requested->contains('ROLE_FOUNDER')) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$nameCanonical = Str::lower(trim($data['name']));
|
||||
$nameConflict = User::query()
|
||||
->where('id', '!=', $user->id)
|
||||
@@ -171,6 +198,19 @@ class UserController extends Controller
|
||||
'rank_id' => $data['rank_id'] ?? null,
|
||||
])->save();
|
||||
|
||||
if (array_key_exists('roles', $data)) {
|
||||
$roleNames = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$roleIds = Role::query()
|
||||
->whereIn('name', $roleNames)
|
||||
->pluck('id')
|
||||
->all();
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
|
||||
$user->loadMissing('rank');
|
||||
|
||||
return response()->json([
|
||||
@@ -181,7 +221,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -194,4 +236,42 @@ class UserController extends Controller
|
||||
|
||||
return Storage::url($user->avatar_path);
|
||||
}
|
||||
|
||||
private function resolveGroupColor(User $user): ?string
|
||||
{
|
||||
$user->loadMissing('roles');
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeRoleName(string $value): string
|
||||
{
|
||||
$raw = strtoupper(trim($value));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
return 'ROLE_';
|
||||
}
|
||||
if (str_starts_with($raw, 'ROLE_')) {
|
||||
return $raw;
|
||||
}
|
||||
return "ROLE_{$raw}";
|
||||
}
|
||||
|
||||
private function isFounder(User $user): bool
|
||||
{
|
||||
return $user->roles()->where('name', 'ROLE_FOUNDER')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -45,4 +46,9 @@ class Post extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function thanks(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
}
|
||||
|
||||
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_text',
|
||||
'badge_image_path',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
|
||||
@@ -25,6 +25,7 @@ class Role extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Notifications\DatabaseNotificationCollection;
|
||||
@@ -106,6 +107,16 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function thanksGiven(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
|
||||
public function thanksReceived(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id');
|
||||
}
|
||||
|
||||
public function rank()
|
||||
{
|
||||
return $this->belongsTo(Rank::class);
|
||||
|
||||
@@ -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...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Allow the installer to run without a .env file.
|
||||
if (!file_exists(__DIR__.'/../.env')) {
|
||||
$tempKey = 'base64:'.base64_encode(random_bytes(32));
|
||||
$_ENV['APP_KEY'] = $tempKey;
|
||||
$_SERVER['APP_KEY'] = $tempKey;
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_SERVER['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
$_SERVER['DB_DATABASE'] = ':memory:';
|
||||
$_ENV['SESSION_DRIVER'] = 'array';
|
||||
$_SERVER['SESSION_DRIVER'] = 'array';
|
||||
$_ENV['SESSION_DOMAIN'] = null;
|
||||
$_SERVER['SESSION_DOMAIN'] = null;
|
||||
$_ENV['SESSION_SECURE_COOKIE'] = false;
|
||||
$_SERVER['SESSION_SECURE_COOKIE'] = false;
|
||||
}
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
@@ -218,11 +218,11 @@ function PortalHeader({
|
||||
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
|
||||
{index > 0 && <span className="bb-portal-sep">›</span>}
|
||||
{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 === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
|
||||
{crumb.label}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={crumb.to} className="bb-portal-link">
|
||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||
@@ -426,7 +426,7 @@ function AppShell() {
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="bb-shell">
|
||||
<div className="bb-shell" id="top">
|
||||
<PortalHeader
|
||||
isAuthenticated={!!token}
|
||||
forumName={settings.forumName}
|
||||
|
||||
@@ -97,6 +97,14 @@ export async function getUserProfile(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() {
|
||||
return apiFetch('/version')
|
||||
}
|
||||
@@ -250,6 +258,30 @@ export async function listRanks() {
|
||||
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) {
|
||||
return apiFetch(`/users/${userId}/rank`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -6,6 +6,14 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
const authorName = thread.user_name || t('thread.anonymous')
|
||||
const lastAuthorName = thread.last_post_user_name || authorName
|
||||
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
|
||||
const 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) => {
|
||||
if (!value) return '—'
|
||||
@@ -34,7 +42,11 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
<div className="bb-portal-topic-meta-line">
|
||||
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
||||
{thread.user_id ? (
|
||||
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
|
||||
<Link
|
||||
to={`/profile/${thread.user_id}`}
|
||||
className="bb-portal-topic-author"
|
||||
style={authorLinkStyle}
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -67,7 +79,11 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
<span className="bb-portal-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{thread.last_post_user_id ? (
|
||||
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
|
||||
<Link
|
||||
to={`/profile/${thread.last_post_user_id}`}
|
||||
className="bb-portal-last-user"
|
||||
style={lastAuthorLinkStyle}
|
||||
>
|
||||
{lastAuthorName}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -360,18 +360,41 @@ a {
|
||||
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 {
|
||||
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 {
|
||||
white-space: pre-wrap;
|
||||
color: var(--bb-ink);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bb-post-footer {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bb-thread-reply {
|
||||
border: 1px solid var(--bb-border);
|
||||
border-radius: 16px;
|
||||
@@ -1194,12 +1217,12 @@ a {
|
||||
}
|
||||
|
||||
.bb-board-last-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-board-last-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1361,6 +1384,45 @@ a {
|
||||
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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -1474,12 +1536,12 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-topic-author {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-author:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1516,12 +1578,12 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-last-user {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-last-user:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1572,11 +1634,11 @@ a {
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1830,6 +1892,15 @@ a {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1863,6 +1934,146 @@ a {
|
||||
.bb-rank-actions {
|
||||
display: inline-flex;
|
||||
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 {
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab,
|
||||
import DataTable, { createTheme } from 'react-data-table-component'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
createForum,
|
||||
deleteForum,
|
||||
fetchSettings,
|
||||
listAllForums,
|
||||
listRanks,
|
||||
listRoles,
|
||||
listUsers,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
@@ -18,6 +20,9 @@ import {
|
||||
updateUserRank,
|
||||
updateRank,
|
||||
updateUser,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
uploadRankBadgeImage,
|
||||
uploadFavicon,
|
||||
uploadLogo,
|
||||
@@ -26,6 +31,8 @@ import {
|
||||
|
||||
export default function Acp({ isAdmin }) {
|
||||
const { t } = useTranslation()
|
||||
const { roles: authRoles } = useAuth()
|
||||
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
|
||||
const [forums, setForums] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -48,8 +55,10 @@ export default function Acp({ isAdmin }) {
|
||||
const [rankFormName, setRankFormName] = useState('')
|
||||
const [rankFormType, setRankFormType] = useState('text')
|
||||
const [rankFormText, setRankFormText] = useState('')
|
||||
const [rankFormColor, setRankFormColor] = useState('')
|
||||
const [rankFormImage, setRankFormImage] = useState(null)
|
||||
const [rankSaving, setRankSaving] = useState(false)
|
||||
const [showRankCreate, setShowRankCreate] = useState(false)
|
||||
const [showRankModal, setShowRankModal] = useState(false)
|
||||
const [rankEdit, setRankEdit] = useState({
|
||||
id: null,
|
||||
@@ -57,10 +66,29 @@ export default function Acp({ isAdmin }) {
|
||||
badgeType: 'text',
|
||||
badgeText: '',
|
||||
badgeImageUrl: '',
|
||||
color: '',
|
||||
})
|
||||
const [rankEditImage, setRankEditImage] = useState(null)
|
||||
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 [generalSaving, setGeneralSaving] = 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" />
|
||||
</Button>
|
||||
{(() => {
|
||||
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
|
||||
return (
|
||||
<Button
|
||||
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={() => {
|
||||
if (editLocked) return
|
||||
setUserForm({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
rankId: row.rank?.id ?? '',
|
||||
roles: row.roles || [],
|
||||
})
|
||||
setShowUserModal(true)
|
||||
setUsersError('')
|
||||
@@ -489,6 +524,8 @@ export default function Acp({ isAdmin }) {
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
<Button
|
||||
variant="dark"
|
||||
title={t('user.delete')}
|
||||
@@ -652,6 +689,39 @@ export default function Acp({ 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 () => {
|
||||
setRanksLoading(true)
|
||||
setRanksError('')
|
||||
@@ -685,6 +755,7 @@ export default function Acp({ isAdmin }) {
|
||||
name: rankFormName.trim(),
|
||||
badge_type: rankFormType,
|
||||
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
||||
color: rankFormColor.trim() || null,
|
||||
})
|
||||
let next = created
|
||||
if (rankFormType === 'image' && rankFormImage) {
|
||||
@@ -695,6 +766,7 @@ export default function Acp({ isAdmin }) {
|
||||
setRankFormName('')
|
||||
setRankFormType('text')
|
||||
setRankFormText('')
|
||||
setRankFormColor('')
|
||||
setRankFormImage(null)
|
||||
} catch (err) {
|
||||
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) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
@@ -1505,76 +1617,108 @@ export default function Acp({ isAdmin }) {
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Row className="g-3 align-items-end mb-3">
|
||||
<Col md={6}>
|
||||
<Form onSubmit={handleCreateRank}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormName}
|
||||
onChange={(event) => setRankFormName(event.target.value)}
|
||||
placeholder={t('rank.name_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-text"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankFormType === 'text'}
|
||||
onChange={() => setRankFormType('text')}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-image"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankFormType === 'image'}
|
||||
onChange={() => setRankFormType('image')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankFormType === 'text' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormText}
|
||||
onChange={(event) => setRankFormText(event.target.value)}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankFormType === 'image' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form>
|
||||
</Col>
|
||||
<Col md="auto">
|
||||
<Tab eventKey="groups" title={t('acp.groups')}>
|
||||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="bb-accent-button"
|
||||
onClick={handleCreateRank}
|
||||
disabled={rankSaving || !rankFormName.trim()}
|
||||
onClick={() => setShowRoleCreate(true)}
|
||||
>
|
||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||
{t('group.create')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
{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 && ranks.length === 0 && (
|
||||
<p className="bb-muted">{t('rank.empty')}</p>
|
||||
@@ -1584,15 +1728,24 @@ export default function Acp({ isAdmin }) {
|
||||
{ranks.map((rank) => (
|
||||
<div key={rank.id} className="bb-rank-row">
|
||||
<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>
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||||
<img src={rank.badge_image_url} alt="" />
|
||||
)}
|
||||
{rank.badge_type !== 'image' && rank.badge_text && (
|
||||
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
@@ -1603,6 +1756,7 @@ export default function Acp({ isAdmin }) {
|
||||
badgeType: rank.badge_type || 'text',
|
||||
badgeText: rank.badge_text || '',
|
||||
badgeImageUrl: rank.badge_image_url || '',
|
||||
color: rank.color || '',
|
||||
})
|
||||
setRankEditImage(null)
|
||||
setShowRankModal(true)
|
||||
@@ -1736,6 +1890,10 @@ export default function Acp({ isAdmin }) {
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder) {
|
||||
setUsersError(t('user.founder_locked'))
|
||||
return
|
||||
}
|
||||
setUserSaving(true)
|
||||
setUsersError('')
|
||||
try {
|
||||
@@ -1744,6 +1902,9 @@ export default function Acp({ isAdmin }) {
|
||||
email: userForm.email,
|
||||
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
||||
}
|
||||
if (roles.length) {
|
||||
payload.roles = userForm.roles || []
|
||||
}
|
||||
const updated = await updateUser(userForm.id, payload)
|
||||
setUsers((prev) =>
|
||||
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.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
@@ -1766,6 +1930,7 @@ export default function Acp({ isAdmin }) {
|
||||
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
required
|
||||
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
@@ -1777,6 +1942,7 @@ export default function Acp({ isAdmin }) {
|
||||
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
||||
}
|
||||
required
|
||||
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
@@ -1786,7 +1952,7 @@ export default function Acp({ isAdmin }) {
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
||||
}
|
||||
disabled={ranksLoading}
|
||||
disabled={ranksLoading || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||||
>
|
||||
<option value="">{t('user.rank_unassigned')}</option>
|
||||
{ranks.map((rank) => (
|
||||
@@ -1796,6 +1962,120 @@ export default function Acp({ isAdmin }) {
|
||||
))}
|
||||
</Form.Select>
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1812,6 +2092,145 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</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
|
||||
show={showRankModal}
|
||||
onHide={() => setShowRankModal(false)}
|
||||
@@ -1840,6 +2259,7 @@ export default function Acp({ isAdmin }) {
|
||||
rankEdit.badgeType === 'text'
|
||||
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
||||
: null,
|
||||
color: rankEdit.color.trim() || null,
|
||||
})
|
||||
let next = updated
|
||||
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
||||
@@ -1869,9 +2289,54 @@ export default function Acp({ isAdmin }) {
|
||||
required
|
||||
/>
|
||||
</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.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
|
||||
type="radio"
|
||||
id="rank-edit-badge-text"
|
||||
@@ -1938,6 +2403,123 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,18 @@ export default function BoardIndex() {
|
||||
<span className="bb-board-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{node.last_post_user_id ? (
|
||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
||||
<Link
|
||||
to={`/profile/${node.last_post_user_id}`}
|
||||
className="bb-board-last-link"
|
||||
style={
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{node.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -44,7 +44,18 @@ export default function ForumView() {
|
||||
<span className="bb-board-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{node.last_post_user_id ? (
|
||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
||||
<Link
|
||||
to={`/profile/${node.last_post_user_id}`}
|
||||
className="bb-board-last-link"
|
||||
style={
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{node.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -226,7 +226,15 @@ export default function Home() {
|
||||
</Link>
|
||||
<div className="bb-portal-user-name">
|
||||
{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'}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUserProfile } from '../api/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } from '../api/client'
|
||||
|
||||
export default function Profile() {
|
||||
const { id } = useParams()
|
||||
@@ -10,23 +11,32 @@ export default function Profile() {
|
||||
const [profile, setProfile] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [thanksGiven, setThanksGiven] = useState([])
|
||||
const [thanksReceived, setThanksReceived] = useState([])
|
||||
const [loadingThanks, setLoadingThanks] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
getUserProfile(id)
|
||||
.then((data) => {
|
||||
Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
|
||||
.then(([profileData, givenData, receivedData]) => {
|
||||
if (!active) return
|
||||
setProfile(data)
|
||||
setProfile(profileData)
|
||||
setThanksGiven(Array.isArray(givenData) ? givenData : [])
|
||||
setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return
|
||||
setError(err.message)
|
||||
setThanksGiven([])
|
||||
setThanksReceived([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
setLoadingThanks(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -34,6 +44,19 @@ export default function Profile() {
|
||||
}
|
||||
}, [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 (
|
||||
<Container fluid className="py-5 bb-portal-shell">
|
||||
<div className="bb-portal-card">
|
||||
@@ -59,6 +82,96 @@ export default function Profile() {
|
||||
</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>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
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 { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThreadView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const { token, userId } = useAuth()
|
||||
const [thread, setThread] = useState(null)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
@@ -56,7 +56,7 @@ export default function ThreadView() {
|
||||
}
|
||||
}
|
||||
|
||||
const replyCount = posts.length
|
||||
// const replyCount = posts.length
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
@@ -72,11 +72,14 @@ export default function ThreadView() {
|
||||
id: `thread-${thread.id}`,
|
||||
body: thread.body,
|
||||
created_at: thread.created_at,
|
||||
user_id: thread.user_id,
|
||||
user_name: thread.user_name,
|
||||
user_avatar_url: thread.user_avatar_url,
|
||||
user_posts_count: thread.user_posts_count,
|
||||
user_created_at: thread.user_created_at,
|
||||
user_location: thread.user_location,
|
||||
user_thanks_given_count: thread.user_thanks_given_count,
|
||||
user_thanks_received_count: thread.user_thanks_received_count,
|
||||
user_rank_name: thread.user_rank_name,
|
||||
user_rank_badge_type: thread.user_rank_badge_type,
|
||||
user_rank_badge_text: thread.user_rank_badge_text,
|
||||
@@ -140,6 +143,17 @@ export default function ThreadView() {
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const currentUserId = Number(userId)
|
||||
const postUserId = Number(post.user_id)
|
||||
const canThank = Number.isFinite(currentUserId)
|
||||
&& Number.isFinite(postUserId)
|
||||
&& currentUserId !== postUserId
|
||||
console.log('canThank check', {
|
||||
postId: post.id,
|
||||
postUserId,
|
||||
currentUserId,
|
||||
canThank,
|
||||
})
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
@@ -190,12 +204,16 @@ export default function ThreadView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Thanks given:</span>
|
||||
<span className="bb-post-author-value">7</span>
|
||||
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_given_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Thanks received:</span>
|
||||
<span className="bb-post-author-value">5</span>
|
||||
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_received_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat bb-post-author-contact">
|
||||
<span className="bb-post-author-label">Contact:</span>
|
||||
@@ -234,12 +252,21 @@ export default function ThreadView() {
|
||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||
<i className="bi bi-quote" aria-hidden="true" />
|
||||
</button>
|
||||
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-house-door" aria-hidden="true" />
|
||||
</a>
|
||||
{canThank && (
|
||||
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
|
||||
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-post-body">{post.body}</div>
|
||||
<div className="bb-post-footer">
|
||||
<div className="bb-post-actions">
|
||||
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-chevron-up" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"acp.add_category": "Kategorie hinzufügen",
|
||||
"acp.add_forum": "Forum hinzufügen",
|
||||
"acp.ranks": "Ränge",
|
||||
"acp.groups": "Gruppen",
|
||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||
"acp.forums_tree": "Forenbaum",
|
||||
"acp.forums_type": "Typ",
|
||||
@@ -112,7 +113,11 @@
|
||||
"rank.badge_type": "Badge-Typ",
|
||||
"rank.badge_text": "Text-Badge",
|
||||
"rank.badge_image": "Bild-Badge",
|
||||
"rank.badge_none": "Kein Badge",
|
||||
"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_image_required": "Badge-Bild ist erforderlich.",
|
||||
"rank.delete_confirm": "Diesen Rang löschen?",
|
||||
@@ -122,6 +127,19 @@
|
||||
"user.impersonate": "Imitieren",
|
||||
"user.edit": "Bearbeiten",
|
||||
"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.range_separator": "von",
|
||||
"home.browse": "Foren durchsuchen",
|
||||
@@ -175,6 +193,11 @@
|
||||
"profile.title": "Profil",
|
||||
"profile.loading": "Profil wird geladen...",
|
||||
"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.profile": "Profil",
|
||||
"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.posts": "Beiträge",
|
||||
"thread.location": "Wohnort",
|
||||
"thread.thanks_given": "Hat sich bedankt",
|
||||
"thread.thanks_received": "Dank erhalten",
|
||||
"thread.thanks": "Danke",
|
||||
"thread.reply_prefix": "Aw:",
|
||||
"thread.registered": "Registriert",
|
||||
"thread.replies": "Antworten",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"acp.add_category": "Add category",
|
||||
"acp.add_forum": "Add forum",
|
||||
"acp.ranks": "Ranks",
|
||||
"acp.groups": "Groups",
|
||||
"acp.forums_parent_root": "Root (no parent)",
|
||||
"acp.forums_tree": "Forum tree",
|
||||
"acp.forums_type": "Type",
|
||||
@@ -112,7 +113,11 @@
|
||||
"rank.badge_type": "Badge type",
|
||||
"rank.badge_text": "Text badge",
|
||||
"rank.badge_image": "Image badge",
|
||||
"rank.badge_none": "No badge",
|
||||
"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_image_required": "Badge image is required.",
|
||||
"rank.delete_confirm": "Delete this rank?",
|
||||
@@ -122,6 +127,19 @@
|
||||
"user.impersonate": "Impersonate",
|
||||
"user.edit": "Edit",
|
||||
"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.range_separator": "of",
|
||||
"home.browse": "Browse forums",
|
||||
@@ -175,6 +193,11 @@
|
||||
"profile.title": "Profile",
|
||||
"profile.loading": "Loading profile...",
|
||||
"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.profile": "Profile",
|
||||
"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.posts": "Posts",
|
||||
"thread.location": "Location",
|
||||
"thread.thanks_given": "Thanks given",
|
||||
"thread.thanks_received": "Thanks received",
|
||||
"thread.thanks": "Thank",
|
||||
"thread.reply_prefix": "Re:",
|
||||
"thread.registered": "Registered",
|
||||
"thread.replies": "Replies",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@vite(['resources/js/main.jsx'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="top"></div>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</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\PortalController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\PostThankController;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Http\Controllers\StatsController;
|
||||
use App\Http\Controllers\ThreadController;
|
||||
@@ -13,6 +14,7 @@ use App\Http\Controllers\UserSettingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\VersionController;
|
||||
use App\Http\Controllers\RankController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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::get('/user/profile/{user}', [UserController::class, 'profile'])->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::post('/ranks', [RankController::class, 'store'])->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::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');
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'app');
|
||||
Route::view('/login', 'app')->name('login');
|
||||
Route::view('/reset-password', 'app')->name('password.reset');
|
||||
Route::get('/install', [InstallerController::class, 'show']);
|
||||
Route::post('/install', [InstallerController::class, 'store'])
|
||||
->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