Add ranks and ACP user enhancements
This commit is contained in:
@@ -6,6 +6,11 @@
|
||||
- Added SPA-friendly verification and password reset/update endpoints.
|
||||
- Added user avatars (upload + display) and a basic profile page/API.
|
||||
- Seeded a Micha test user with verified email.
|
||||
- Added rank management with badge text/image options and ACP UI controls.
|
||||
- Added user edit modal (name/email/rank) and rank assignment controls in ACP.
|
||||
- Added ACP users search and improved sorting indicators.
|
||||
- Added thread sidebar fields for posts count, registration date, and topic header.
|
||||
- Linked header logo to the portal and fixed ACP breadcrumbs.
|
||||
|
||||
## 2026-01-11
|
||||
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
||||
|
||||
@@ -12,7 +12,9 @@ class PostController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Post::query()->withoutTrashed()->with('user');
|
||||
$query = Post::query()->withoutTrashed()->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
]);
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
if (is_string($threadParam)) {
|
||||
@@ -46,7 +48,9 @@ class PostController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$post->loadMissing('user');
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
}
|
||||
@@ -88,6 +92,14 @@ class PostController extends Controller
|
||||
'user_avatar_url' => $post->user?->avatar_path
|
||||
? Storage::url($post->user->avatar_path)
|
||||
: null,
|
||||
'user_posts_count' => $post->user?->posts_count,
|
||||
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
||||
'user_rank_name' => $post->user?->rank?->name,
|
||||
'user_rank_badge_type' => $post->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $post->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
|
||||
? Storage::url($post->user->rank->badge_image_path)
|
||||
: null,
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
153
app/Http/Controllers/RankController.php
Normal file
153
app/Http/Controllers/RankController.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Rank;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class RankController extends Controller
|
||||
{
|
||||
private function ensureAdmin(Request $request): ?JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$ranks = Rank::query()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Rank $rank) => [
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
]);
|
||||
|
||||
return response()->json($ranks);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $data['name'])
|
||||
: null;
|
||||
|
||||
$rank = Rank::create([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => null,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, Rank $rank): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||
: null;
|
||||
|
||||
if ($badgeType === 'text' && $rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
$rank->badge_image_path = null;
|
||||
}
|
||||
|
||||
$rank->update([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Rank $rank): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if ($rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
}
|
||||
|
||||
$rank->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function uploadBadgeImage(Request $request, Rank $rank): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'],
|
||||
]);
|
||||
|
||||
if ($rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
}
|
||||
|
||||
$path = $data['file']->store('rank-badges', 'public');
|
||||
$rank->badge_type = 'image';
|
||||
$rank->badge_text = null;
|
||||
$rank->badge_image_path = $path;
|
||||
$rank->save();
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => Storage::url($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ class ThreadController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Thread::query()->withoutTrashed()->with('user');
|
||||
$query = Thread::query()->withoutTrashed()->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
]);
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
if (is_string($forumParam)) {
|
||||
@@ -32,7 +34,9 @@ class ThreadController extends Controller
|
||||
|
||||
public function show(Thread $thread): JsonResponse
|
||||
{
|
||||
$thread->loadMissing('user');
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
]);
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
@@ -99,6 +103,14 @@ class ThreadController extends Controller
|
||||
'user_avatar_url' => $thread->user?->avatar_path
|
||||
? Storage::url($thread->user->avatar_path)
|
||||
: null,
|
||||
'user_posts_count' => $thread->user?->posts_count,
|
||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||
'user_rank_name' => $thread->user?->rank?->name,
|
||||
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
@@ -6,13 +6,15 @@ use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$users = User::query()
|
||||
->with('roles')
|
||||
->with(['roles', 'rank'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (User $user) => [
|
||||
@@ -20,6 +22,10 @@ class UserController extends Controller
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
]);
|
||||
|
||||
@@ -39,6 +45,10 @@ class UserController extends Controller
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -49,10 +59,94 @@ class UserController extends Controller
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'created_at' => $user->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateRank(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$actor = $request->user();
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
]);
|
||||
|
||||
$user->rank_id = $data['rank_id'] ?? null;
|
||||
$user->save();
|
||||
|
||||
$user->loadMissing('rank');
|
||||
|
||||
return response()->json([
|
||||
'id' => $user->id,
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$actor = $request->user();
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users', 'email')->ignore($user->id),
|
||||
],
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
]);
|
||||
|
||||
$nameCanonical = Str::lower(trim($data['name']));
|
||||
$nameConflict = User::query()
|
||||
->where('id', '!=', $user->id)
|
||||
->where('name_canonical', $nameCanonical)
|
||||
->exists();
|
||||
|
||||
if ($nameConflict) {
|
||||
return response()->json(['message' => 'Name already exists.'], 422);
|
||||
}
|
||||
|
||||
if ($data['email'] !== $user->email) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'name' => $data['name'],
|
||||
'name_canonical' => $nameCanonical,
|
||||
'email' => $data['email'],
|
||||
'rank_id' => $data['rank_id'] ?? null,
|
||||
])->save();
|
||||
|
||||
$user->loadMissing('rank');
|
||||
|
||||
return response()->json([
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveAvatarUrl(User $user): ?string
|
||||
{
|
||||
if (!$user->avatar_path) {
|
||||
|
||||
28
app/Models/Rank.php
Normal file
28
app/Models/Rank.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $users
|
||||
*/
|
||||
class Rank extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'badge_type',
|
||||
'badge_text',
|
||||
'badge_image_path',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
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\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
@@ -66,6 +67,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'name',
|
||||
'name_canonical',
|
||||
'avatar_path',
|
||||
'rank_id',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
@@ -97,4 +99,14 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function rank()
|
||||
{
|
||||
return $this->belongsTo(Rank::class);
|
||||
}
|
||||
}
|
||||
|
||||
28
database/migrations/2026_01_05_020000_create_ranks_table.php
Normal file
28
database/migrations/2026_01_05_020000_create_ranks_table.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ranks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ranks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('rank_id')->nullable()->after('avatar_path')->constrained('ranks')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('rank_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ranks', function (Blueprint $table) {
|
||||
$table->string('badge_type')->default('text')->after('name');
|
||||
$table->string('badge_text')->nullable()->after('badge_type');
|
||||
$table->string('badge_image_path')->nullable()->after('badge_text');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ranks', function (Blueprint $table) {
|
||||
$table->dropColumn(['badge_type', 'badge_text', 'badge_image_path']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([
|
||||
RoleSeeder::class,
|
||||
RankSeeder::class,
|
||||
UserSeeder::class,
|
||||
ForumSeeder::class,
|
||||
ThreadSeeder::class,
|
||||
|
||||
41
database/seeders/RankSeeder.php
Normal file
41
database/seeders/RankSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Rank;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class RankSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$member = Rank::firstOrCreate(
|
||||
['name' => 'Member'],
|
||||
['badge_type' => 'text', 'badge_text' => 'Member']
|
||||
);
|
||||
$operator = Rank::firstOrCreate(
|
||||
['name' => 'Operator'],
|
||||
['badge_type' => 'text', 'badge_text' => 'Operator']
|
||||
);
|
||||
$moderator = Rank::firstOrCreate(
|
||||
['name' => 'Moderator'],
|
||||
['badge_type' => 'text', 'badge_text' => 'Moderator']
|
||||
);
|
||||
|
||||
User::query()
|
||||
->whereNull('rank_id')
|
||||
->update(['rank_id' => $member->id]);
|
||||
|
||||
User::query()
|
||||
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_ADMIN'))
|
||||
->update(['rank_id' => $operator->id]);
|
||||
|
||||
User::query()
|
||||
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_MODERATOR'))
|
||||
->update(['rank_id' => $moderator->id]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Database\Seeders;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
|
||||
@@ -17,12 +18,15 @@ class UserSeeder extends Seeder
|
||||
{
|
||||
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
|
||||
$userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first();
|
||||
$operatorRank = Rank::where('name', 'Operator')->first();
|
||||
$memberRank = Rank::where('name', 'Member')->first();
|
||||
|
||||
$admin = User::updateOrCreate(
|
||||
attributes: ['email' => 'tracer@24unix.net'],
|
||||
values : [
|
||||
'name' => 'tracer',
|
||||
'name_canonical' => Str::lower('tracer'),
|
||||
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
|
||||
'password' => Hash::make(value: 'password'),
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
@@ -33,6 +37,7 @@ class UserSeeder extends Seeder
|
||||
values : [
|
||||
'name' => 'Micha',
|
||||
'name_canonical' => Str::lower('Micha'),
|
||||
'rank_id' => $memberRank?->id,
|
||||
'password' => Hash::make(value: 'password'),
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
|
||||
@@ -105,6 +105,15 @@ function PortalHeader({
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/acp')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.link_acp'), to: '/acp', current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
}
|
||||
|
||||
@@ -119,12 +128,14 @@ function PortalHeader({
|
||||
<Container fluid className="pt-2 pb-2 bb-portal-shell">
|
||||
<div className="bb-portal-banner">
|
||||
<div className="bb-portal-brand">
|
||||
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||
{logoUrl && (
|
||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||
)}
|
||||
{(showHeaderName || !logoUrl) && (
|
||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bb-portal-search">
|
||||
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
||||
|
||||
@@ -228,6 +228,53 @@ export async function listUsers() {
|
||||
return getCollection('/users')
|
||||
}
|
||||
|
||||
export async function listRanks() {
|
||||
return getCollection('/ranks')
|
||||
}
|
||||
|
||||
export async function updateUserRank(userId, rankId) {
|
||||
return apiFetch(`/users/${userId}/rank`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ rank_id: rankId }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createRank(payload) {
|
||||
return apiFetch('/ranks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateRank(rankId, payload) {
|
||||
return apiFetch(`/ranks/${rankId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteRank(rankId) {
|
||||
return apiFetch(`/ranks/${rankId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadRankBadgeImage(rankId, file) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return apiFetch(`/ranks/${rankId}/badge-image`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateUser(userId, payload) {
|
||||
return apiFetch(`/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createThread({ title, body, forumId }) {
|
||||
return apiFetch('/threads', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -231,6 +231,10 @@ a {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bb-post-avatar i {
|
||||
font-size: 4.4rem;
|
||||
}
|
||||
|
||||
.bb-post-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -263,6 +267,17 @@ a {
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
width: fit-content;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bb-post-author-badge img {
|
||||
width: auto;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bb-post-author-badge span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bb-post-author-meta {
|
||||
@@ -317,6 +332,12 @@ a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bb-post-topic {
|
||||
font-weight: 600;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-post-actions {
|
||||
@@ -847,6 +868,13 @@ a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bb-portal-logo-link {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bb-portal-logo-image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
@@ -1665,6 +1693,101 @@ a {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bb-rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-rank-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(18, 23, 33, 0.8);
|
||||
}
|
||||
|
||||
.bb-rank-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-rank-main img {
|
||||
height: 22px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bb-rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
|
||||
color: #7b1f2a;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.bb-rank-badge-preview {
|
||||
padding: 0.4rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bb-rank-badge-preview img {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bb-rank-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bb-user-search {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.bb-sort-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bb-sort-label i {
|
||||
font-size: 0.9rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.rdt_TableCol_Sortable svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rdt_TableCol_Sortable .__rdt_custom_sort_icon__ {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bb-sort-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rdt_TableCol_Sortable .__rdt_custom_sort_icon__ i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-drag-handle {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
deleteForum,
|
||||
fetchSettings,
|
||||
listAllForums,
|
||||
listRanks,
|
||||
listUsers,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
saveSettings,
|
||||
createRank,
|
||||
deleteRank,
|
||||
updateUserRank,
|
||||
updateRank,
|
||||
updateUser,
|
||||
uploadRankBadgeImage,
|
||||
uploadFavicon,
|
||||
uploadLogo,
|
||||
updateForum,
|
||||
@@ -28,10 +35,33 @@ export default function Acp({ isAdmin }) {
|
||||
const pendingOrder = useRef(null)
|
||||
const [createType, setCreateType] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [usersError, setUsersError] = useState('')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||
const [ranks, setRanks] = useState([])
|
||||
const [ranksLoading, setRanksLoading] = useState(false)
|
||||
const [ranksError, setRanksError] = useState('')
|
||||
const [rankUpdatingId, setRankUpdatingId] = useState(null)
|
||||
const [rankFormName, setRankFormName] = useState('')
|
||||
const [rankFormType, setRankFormType] = useState('text')
|
||||
const [rankFormText, setRankFormText] = useState('')
|
||||
const [rankFormImage, setRankFormImage] = useState(null)
|
||||
const [rankSaving, setRankSaving] = useState(false)
|
||||
const [showRankModal, setShowRankModal] = useState(false)
|
||||
const [rankEdit, setRankEdit] = useState({
|
||||
id: null,
|
||||
name: '',
|
||||
badgeType: 'text',
|
||||
badgeText: '',
|
||||
badgeImageUrl: '',
|
||||
})
|
||||
const [rankEditImage, setRankEditImage] = useState(null)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
|
||||
const [userSaving, setUserSaving] = useState(false)
|
||||
const [generalSaving, setGeneralSaving] = useState(false)
|
||||
const [generalUploading, setGeneralUploading] = useState(false)
|
||||
const [generalError, setGeneralError] = useState('')
|
||||
@@ -341,17 +371,93 @@ export default function Acp({ isAdmin }) {
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const filteredUsers = useMemo(() => {
|
||||
const term = userSearch.trim().toLowerCase()
|
||||
if (!term) return users
|
||||
return users.filter((user) =>
|
||||
[user.name, user.email, user.rank?.name]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(term))
|
||||
)
|
||||
}, [users, userSearch])
|
||||
|
||||
const userColumns = useMemo(
|
||||
() => [
|
||||
() => {
|
||||
const iconFor = (id) => {
|
||||
if (userSort.columnId !== id) {
|
||||
return 'bi-arrow-down-up'
|
||||
}
|
||||
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: t('user.name'),
|
||||
id: 'name',
|
||||
name: (
|
||||
<span className="bb-sort-label">
|
||||
{t('user.name')}
|
||||
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
|
||||
</span>
|
||||
),
|
||||
selector: (row) => row.name,
|
||||
sortable: true,
|
||||
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
|
||||
sensitivity: 'base',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: (
|
||||
<span className="bb-sort-label">
|
||||
{t('user.email')}
|
||||
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
|
||||
</span>
|
||||
),
|
||||
selector: (row) => row.email,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: t('user.email'),
|
||||
selector: (row) => row.email,
|
||||
id: 'rank',
|
||||
name: (
|
||||
<span className="bb-sort-label">
|
||||
{t('user.rank')}
|
||||
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
|
||||
</span>
|
||||
),
|
||||
width: '220px',
|
||||
sortable: true,
|
||||
sortFunction: (a, b) =>
|
||||
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
|
||||
cell: (row) => (
|
||||
<Form.Select
|
||||
size="sm"
|
||||
value={row.rank?.id ?? ''}
|
||||
disabled={ranksLoading || rankUpdatingId === row.id}
|
||||
onChange={async (event) => {
|
||||
const nextRankId = event.target.value ? Number(event.target.value) : null
|
||||
setRankUpdatingId(row.id)
|
||||
try {
|
||||
const updated = await updateUserRank(row.id, nextRankId)
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === row.id ? { ...user, rank: updated.rank } : user
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setUsersError(err.message)
|
||||
} finally {
|
||||
setRankUpdatingId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{t('user.rank_unassigned')}</option>
|
||||
{ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
@@ -370,7 +476,16 @@ export default function Acp({ isAdmin }) {
|
||||
<Button
|
||||
variant="dark"
|
||||
title={t('user.edit')}
|
||||
onClick={() => console.log('edit user', row)}
|
||||
onClick={() => {
|
||||
setUserForm({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
rankId: row.rank?.id ?? '',
|
||||
})
|
||||
setShowUserModal(true)
|
||||
setUsersError('')
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -385,8 +500,9 @@ export default function Acp({ isAdmin }) {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
]
|
||||
},
|
||||
[t, ranks, ranksLoading, rankUpdatingId, userSort]
|
||||
)
|
||||
const userTableStyles = useMemo(
|
||||
() => ({
|
||||
@@ -536,6 +652,57 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshRanks = async () => {
|
||||
setRanksLoading(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
const data = await listRanks()
|
||||
setRanks(data)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRanksLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
refreshRanks()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const handleCreateRank = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!rankFormName.trim()) return
|
||||
if (rankFormType === 'image' && !rankFormImage) {
|
||||
setRanksError(t('rank.badge_image_required'))
|
||||
return
|
||||
}
|
||||
setRankSaving(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
const created = await createRank({
|
||||
name: rankFormName.trim(),
|
||||
badge_type: rankFormType,
|
||||
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
||||
})
|
||||
let next = created
|
||||
if (rankFormType === 'image' && rankFormImage) {
|
||||
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
|
||||
next = { ...created, ...updated }
|
||||
}
|
||||
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
setRankFormName('')
|
||||
setRankFormType('text')
|
||||
setRankFormText('')
|
||||
setRankFormImage(null)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRankSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
@@ -1298,17 +1465,23 @@ export default function Acp({ isAdmin }) {
|
||||
</Tab>
|
||||
<Tab eventKey="users" title={t('acp.users')}>
|
||||
{usersError && <p className="text-danger">{usersError}</p>}
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!usersLoading && (
|
||||
<DataTable
|
||||
columns={userColumns}
|
||||
data={users}
|
||||
data={filteredUsers}
|
||||
pagination
|
||||
striped
|
||||
highlightOnHover={themeMode !== 'dark'}
|
||||
dense
|
||||
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
||||
customStyles={userTableStyles}
|
||||
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
|
||||
defaultSortFieldId="name"
|
||||
onSort={(column, direction) => {
|
||||
setUserSort({ columnId: column.id, direction })
|
||||
}}
|
||||
paginationComponentOptions={{
|
||||
rowsPerPageText: t('table.rows_per_page'),
|
||||
rangeSeparatorText: t('table.range_separator'),
|
||||
@@ -1320,7 +1493,147 @@ export default function Acp({ isAdmin }) {
|
||||
setUsersPage(1)
|
||||
}}
|
||||
paginationComponent={UsersPagination}
|
||||
subHeader
|
||||
subHeaderComponent={
|
||||
<Form.Control
|
||||
className="bb-user-search"
|
||||
value={userSearch}
|
||||
onChange={(event) => setUserSearch(event.target.value)}
|
||||
placeholder={t('user.search')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Row className="g-3 align-items-end mb-3">
|
||||
<Col md={6}>
|
||||
<Form onSubmit={handleCreateRank}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormName}
|
||||
onChange={(event) => setRankFormName(event.target.value)}
|
||||
placeholder={t('rank.name_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-text"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankFormType === 'text'}
|
||||
onChange={() => setRankFormType('text')}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-image"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankFormType === 'image'}
|
||||
onChange={() => setRankFormType('image')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankFormType === 'text' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormText}
|
||||
onChange={(event) => setRankFormText(event.target.value)}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankFormType === 'image' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form>
|
||||
</Col>
|
||||
<Col md="auto">
|
||||
<Button
|
||||
type="button"
|
||||
className="bb-accent-button"
|
||||
onClick={handleCreateRank}
|
||||
disabled={rankSaving || !rankFormName.trim()}
|
||||
>
|
||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!ranksLoading && ranks.length === 0 && (
|
||||
<p className="bb-muted">{t('rank.empty')}</p>
|
||||
)}
|
||||
{!ranksLoading && ranks.length > 0 && (
|
||||
<div className="bb-rank-list">
|
||||
{ranks.map((rank) => (
|
||||
<div key={rank.id} className="bb-rank-row">
|
||||
<div className="bb-rank-main">
|
||||
<span>{rank.name}</span>
|
||||
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||||
<img src={rank.badge_image_url} alt="" />
|
||||
)}
|
||||
{rank.badge_type !== 'image' && rank.badge_text && (
|
||||
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={() => {
|
||||
setRankEdit({
|
||||
id: rank.id,
|
||||
name: rank.name,
|
||||
badgeType: rank.badge_type || 'text',
|
||||
badgeText: rank.badge_text || '',
|
||||
badgeImageUrl: rank.badge_image_url || '',
|
||||
})
|
||||
setRankEditImage(null)
|
||||
setShowRankModal(true)
|
||||
setRanksError('')
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t('rank.delete_confirm'))) return
|
||||
setRankSaving(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
await deleteRank(rank.id)
|
||||
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRankSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -1410,6 +1723,221 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showUserModal}
|
||||
onHide={() => setShowUserModal(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('user.edit_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{usersError && <p className="text-danger">{usersError}</p>}
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
setUserSaving(true)
|
||||
setUsersError('')
|
||||
try {
|
||||
const payload = {
|
||||
name: userForm.name,
|
||||
email: userForm.email,
|
||||
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
||||
}
|
||||
const updated = await updateUser(userForm.id, payload)
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === updated.id ? { ...user, ...updated } : user
|
||||
)
|
||||
)
|
||||
setShowUserModal(false)
|
||||
} catch (err) {
|
||||
setUsersError(err.message)
|
||||
} finally {
|
||||
setUserSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
value={userForm.name}
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={userForm.email}
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('user.rank')}</Form.Label>
|
||||
<Form.Select
|
||||
value={userForm.rankId ?? ''}
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
||||
}
|
||||
disabled={ranksLoading}
|
||||
>
|
||||
<option value="">{t('user.rank_unassigned')}</option>
|
||||
{ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowUserModal(false)}
|
||||
disabled={userSaving}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
|
||||
{userSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRankModal}
|
||||
onHide={() => setShowRankModal(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('rank.edit_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (!rankEdit.name.trim()) return
|
||||
if (rankEdit.badgeType === 'text' && !rankEdit.badgeText.trim()) {
|
||||
setRanksError(t('rank.badge_text_required'))
|
||||
return
|
||||
}
|
||||
setRankSaving(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
const updated = await updateRank(rankEdit.id, {
|
||||
name: rankEdit.name.trim(),
|
||||
badge_type: rankEdit.badgeType,
|
||||
badge_text:
|
||||
rankEdit.badgeType === 'text'
|
||||
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
||||
: null,
|
||||
})
|
||||
let next = updated
|
||||
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
||||
const upload = await uploadRankBadgeImage(rankEdit.id, rankEditImage)
|
||||
next = { ...updated, ...upload }
|
||||
}
|
||||
setRanks((prev) =>
|
||||
prev
|
||||
.map((item) => (item.id === next.id ? { ...item, ...next } : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
setShowRankModal(false)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRankSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankEdit.name}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-edit-badge-text"
|
||||
name="rankEditBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankEdit.badgeType === 'text'}
|
||||
onChange={() =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
|
||||
}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-edit-badge-image"
|
||||
name="rankEditBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankEdit.badgeType === 'image'}
|
||||
onChange={() =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankEdit.badgeType === 'text' && (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankEdit.badgeText}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
|
||||
}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankEdit.badgeType === 'image' && (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
{rankEdit.badgeImageUrl && !rankEditImage && (
|
||||
<div className="bb-rank-badge-preview">
|
||||
<img src={rankEdit.badgeImageUrl} alt="" />
|
||||
</div>
|
||||
)}
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowRankModal(false)}
|
||||
disabled={rankSaving}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
|
||||
{rankSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ export default function ThreadView() {
|
||||
}
|
||||
|
||||
const replyCount = posts.length
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
const allPosts = useMemo(() => {
|
||||
if (!thread) return posts
|
||||
const rootPost = {
|
||||
@@ -53,6 +62,12 @@ export default function ThreadView() {
|
||||
created_at: thread.created_at,
|
||||
user_name: thread.user_name,
|
||||
user_avatar_url: thread.user_avatar_url,
|
||||
user_posts_count: thread.user_posts_count,
|
||||
user_created_at: thread.user_created_at,
|
||||
user_rank_name: thread.user_rank_name,
|
||||
user_rank_badge_type: thread.user_rank_badge_type,
|
||||
user_rank_badge_text: thread.user_rank_badge_text,
|
||||
user_rank_badge_url: thread.user_rank_badge_url,
|
||||
isRoot: true,
|
||||
}
|
||||
return [rootPost, ...posts]
|
||||
@@ -107,11 +122,17 @@ export default function ThreadView() {
|
||||
</div>
|
||||
|
||||
<div className="bb-posts">
|
||||
{allPosts.map((post) => {
|
||||
{allPosts.map((post, index) => {
|
||||
const authorName = post.author?.username
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
: `${t('thread.reply_prefix')} ${thread.title}`
|
||||
: ''
|
||||
const postNumber = index + 1
|
||||
|
||||
return (
|
||||
<article className="bb-post-row" key={post.id}>
|
||||
@@ -124,16 +145,30 @@ export default function ThreadView() {
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-author-name">{authorName}</div>
|
||||
<div className="bb-post-author-role">Operator</div>
|
||||
<div className="bb-post-author-badge">TEAM-RHF</div>
|
||||
<div className="bb-post-author-role">
|
||||
{post.user_rank_name || ''}
|
||||
</div>
|
||||
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
|
||||
<div className="bb-post-author-badge">
|
||||
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
|
||||
<img src={post.user_rank_badge_url} alt="" />
|
||||
) : (
|
||||
<span>{post.user_rank_badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bb-post-author-meta">
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Posts:</span>
|
||||
<span className="bb-post-author-value">63899</span>
|
||||
<span className="bb-post-author-label">{t('thread.posts')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_posts_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Registered:</span>
|
||||
<span className="bb-post-author-value">18.08.2004 18:50:03</span>
|
||||
<span className="bb-post-author-label">{t('thread.registered')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{formatDate(post.user_created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Location:</span>
|
||||
@@ -158,6 +193,11 @@ export default function ThreadView() {
|
||||
<div className="bb-post-content">
|
||||
<div className="bb-post-header">
|
||||
<div className="bb-post-header-meta">
|
||||
{topicLabel && (
|
||||
<span className="bb-post-topic">
|
||||
#{postNumber} {topicLabel}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('thread.by')} {authorName}</span>
|
||||
{post.created_at && (
|
||||
<span>{post.created_at.slice(0, 10)}</span>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"acp.show_header_name": "Forenname im Header anzeigen",
|
||||
"acp.add_category": "Kategorie hinzufügen",
|
||||
"acp.add_forum": "Forum hinzufügen",
|
||||
"acp.ranks": "Ränge",
|
||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||
"acp.forums_tree": "Forenbaum",
|
||||
"acp.forums_type": "Typ",
|
||||
@@ -101,6 +102,22 @@
|
||||
"user.id": "ID",
|
||||
"user.name": "Name",
|
||||
"user.email": "E-Mail",
|
||||
"user.rank": "Rang",
|
||||
"user.rank_unassigned": "Nicht zugewiesen",
|
||||
"user.edit_title": "Benutzer bearbeiten",
|
||||
"user.search": "Benutzer suchen...",
|
||||
"rank.name": "Rangname",
|
||||
"rank.name_placeholder": "z. B. Operator",
|
||||
"rank.create": "Rang erstellen",
|
||||
"rank.edit_title": "Rang bearbeiten",
|
||||
"rank.badge_type": "Badge-Typ",
|
||||
"rank.badge_text": "Text-Badge",
|
||||
"rank.badge_image": "Bild-Badge",
|
||||
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
|
||||
"rank.badge_text_required": "Badge-Text ist erforderlich.",
|
||||
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
|
||||
"rank.delete_confirm": "Diesen Rang löschen?",
|
||||
"rank.empty": "Noch keine Ränge vorhanden.",
|
||||
"user.roles": "Rollen",
|
||||
"user.actions": "Aktionen",
|
||||
"user.impersonate": "Imitieren",
|
||||
@@ -173,6 +190,9 @@
|
||||
"thread.label": "Thread",
|
||||
"thread.loading": "Thread wird geladen...",
|
||||
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
||||
"thread.posts": "Beiträge",
|
||||
"thread.reply_prefix": "Aw:",
|
||||
"thread.registered": "Registriert",
|
||||
"thread.replies": "Antworten",
|
||||
"thread.views": "Zugriffe",
|
||||
"thread.last_post": "Letzter Beitrag",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"acp.show_header_name": "Display name in header",
|
||||
"acp.add_category": "Add category",
|
||||
"acp.add_forum": "Add forum",
|
||||
"acp.ranks": "Ranks",
|
||||
"acp.forums_parent_root": "Root (no parent)",
|
||||
"acp.forums_tree": "Forum tree",
|
||||
"acp.forums_type": "Type",
|
||||
@@ -101,6 +102,22 @@
|
||||
"user.id": "ID",
|
||||
"user.name": "Name",
|
||||
"user.email": "Email",
|
||||
"user.rank": "Rank",
|
||||
"user.rank_unassigned": "Unassigned",
|
||||
"user.edit_title": "Edit user",
|
||||
"user.search": "Search users...",
|
||||
"rank.name": "Rank name",
|
||||
"rank.name_placeholder": "e.g. Operator",
|
||||
"rank.create": "Create rank",
|
||||
"rank.edit_title": "Edit rank",
|
||||
"rank.badge_type": "Badge type",
|
||||
"rank.badge_text": "Text badge",
|
||||
"rank.badge_image": "Image badge",
|
||||
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
|
||||
"rank.badge_text_required": "Badge text is required.",
|
||||
"rank.badge_image_required": "Badge image is required.",
|
||||
"rank.delete_confirm": "Delete this rank?",
|
||||
"rank.empty": "No ranks created yet.",
|
||||
"user.roles": "Roles",
|
||||
"user.actions": "Actions",
|
||||
"user.impersonate": "Impersonate",
|
||||
@@ -173,6 +190,9 @@
|
||||
"thread.label": "Thread",
|
||||
"thread.loading": "Loading thread...",
|
||||
"thread.login_hint": "Log in to reply to this thread.",
|
||||
"thread.posts": "Posts",
|
||||
"thread.reply_prefix": "Re:",
|
||||
"thread.registered": "Registered",
|
||||
"thread.replies": "Replies",
|
||||
"thread.views": "Views",
|
||||
"thread.last_post": "Last post",
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\UploadController;
|
||||
use App\Http\Controllers\UserSettingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\VersionController;
|
||||
use App\Http\Controllers\RankController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
@@ -33,8 +34,15 @@ Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->midd
|
||||
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
|
||||
Route::get('/i18n/{locale}', I18nController::class);
|
||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
||||
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
|
||||
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
|
||||
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
|
||||
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
|
||||
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
|
||||
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum');
|
||||
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
||||
|
||||
Route::get('/forums', [ForumController::class, 'index']);
|
||||
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
||||
|
||||
Reference in New Issue
Block a user