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 SPA-friendly verification and password reset/update endpoints.
|
||||||
- Added user avatars (upload + display) and a basic profile page/API.
|
- Added user avatars (upload + display) and a basic profile page/API.
|
||||||
- Seeded a Micha test user with verified email.
|
- 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
|
## 2026-01-11
|
||||||
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
- 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
|
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');
|
$threadParam = $request->query('thread');
|
||||||
if (is_string($threadParam)) {
|
if (is_string($threadParam)) {
|
||||||
@@ -46,7 +48,9 @@ class PostController extends Controller
|
|||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$post->loadMissing('user');
|
$post->loadMissing([
|
||||||
|
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json($this->serializePost($post), 201);
|
return response()->json($this->serializePost($post), 201);
|
||||||
}
|
}
|
||||||
@@ -88,6 +92,14 @@ class PostController extends Controller
|
|||||||
'user_avatar_url' => $post->user?->avatar_path
|
'user_avatar_url' => $post->user?->avatar_path
|
||||||
? Storage::url($post->user->avatar_path)
|
? Storage::url($post->user->avatar_path)
|
||||||
: null,
|
: 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(),
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
'updated_at' => $post->updated_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
|
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');
|
$forumParam = $request->query('forum');
|
||||||
if (is_string($forumParam)) {
|
if (is_string($forumParam)) {
|
||||||
@@ -32,7 +34,9 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
public function show(Thread $thread): JsonResponse
|
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));
|
return response()->json($this->serializeThread($thread));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +103,14 @@ class ThreadController extends Controller
|
|||||||
'user_avatar_url' => $thread->user?->avatar_path
|
'user_avatar_url' => $thread->user?->avatar_path
|
||||||
? Storage::url($thread->user->avatar_path)
|
? Storage::url($thread->user->avatar_path)
|
||||||
: null,
|
: 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(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ use App\Models\User;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->with('roles')
|
->with(['roles', 'rank'])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get()
|
->get()
|
||||||
->map(fn (User $user) => [
|
->map(fn (User $user) => [
|
||||||
@@ -20,6 +22,10 @@ class UserController extends Controller
|
|||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||||
|
'rank' => $user->rank ? [
|
||||||
|
'id' => $user->rank->id,
|
||||||
|
'name' => $user->rank->name,
|
||||||
|
] : null,
|
||||||
'roles' => $user->roles->pluck('name')->values(),
|
'roles' => $user->roles->pluck('name')->values(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -39,6 +45,10 @@ class UserController extends Controller
|
|||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||||
|
'rank' => $user->rank ? [
|
||||||
|
'id' => $user->rank->id,
|
||||||
|
'name' => $user->rank->name,
|
||||||
|
] : null,
|
||||||
'roles' => $user->roles()->pluck('name')->values(),
|
'roles' => $user->roles()->pluck('name')->values(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -49,10 +59,94 @@ class UserController extends Controller
|
|||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||||
|
'rank' => $user->rank ? [
|
||||||
|
'id' => $user->rank->id,
|
||||||
|
'name' => $user->rank->name,
|
||||||
|
] : null,
|
||||||
'created_at' => $user->created_at?->toIso8601String(),
|
'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
|
private function resolveAvatarUrl(User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user->avatar_path) {
|
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\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\DatabaseNotification;
|
use Illuminate\Notifications\DatabaseNotification;
|
||||||
@@ -66,6 +67,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'name',
|
'name',
|
||||||
'name_canonical',
|
'name_canonical',
|
||||||
'avatar_path',
|
'avatar_path',
|
||||||
|
'rank_id',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
];
|
];
|
||||||
@@ -97,4 +99,14 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
{
|
{
|
||||||
return $this->belongsToMany(Role::class);
|
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([
|
$this->call([
|
||||||
RoleSeeder::class,
|
RoleSeeder::class,
|
||||||
|
RankSeeder::class,
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
ForumSeeder::class,
|
ForumSeeder::class,
|
||||||
ThreadSeeder::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\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use App\Models\Rank;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
@@ -17,12 +18,15 @@ class UserSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
|
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
|
||||||
$userRole = Role::where(column: 'name', operator: 'ROLE_USER')->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(
|
$admin = User::updateOrCreate(
|
||||||
attributes: ['email' => 'tracer@24unix.net'],
|
attributes: ['email' => 'tracer@24unix.net'],
|
||||||
values : [
|
values : [
|
||||||
'name' => 'tracer',
|
'name' => 'tracer',
|
||||||
'name_canonical' => Str::lower('tracer'),
|
'name_canonical' => Str::lower('tracer'),
|
||||||
|
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
|
||||||
'password' => Hash::make(value: 'password'),
|
'password' => Hash::make(value: 'password'),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
]
|
]
|
||||||
@@ -33,6 +37,7 @@ class UserSeeder extends Seeder
|
|||||||
values : [
|
values : [
|
||||||
'name' => 'Micha',
|
'name' => 'Micha',
|
||||||
'name_canonical' => Str::lower('Micha'),
|
'name_canonical' => Str::lower('Micha'),
|
||||||
|
'rank_id' => $memberRank?->id,
|
||||||
'password' => Hash::make(value: 'password'),
|
'password' => Hash::make(value: 'password'),
|
||||||
'email_verified_at' => now(),
|
'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 }])
|
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +128,14 @@ function PortalHeader({
|
|||||||
<Container fluid className="pt-2 pb-2 bb-portal-shell">
|
<Container fluid className="pt-2 pb-2 bb-portal-shell">
|
||||||
<div className="bb-portal-banner">
|
<div className="bb-portal-banner">
|
||||||
<div className="bb-portal-brand">
|
<div className="bb-portal-brand">
|
||||||
|
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||||
)}
|
)}
|
||||||
{(showHeaderName || !logoUrl) && (
|
{(showHeaderName || !logoUrl) && (
|
||||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||||
)}
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-portal-search">
|
<div className="bb-portal-search">
|
||||||
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
||||||
|
|||||||
@@ -228,6 +228,53 @@ export async function listUsers() {
|
|||||||
return getCollection('/users')
|
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 }) {
|
export async function createThread({ title, body, forumId }) {
|
||||||
return apiFetch('/threads', {
|
return apiFetch('/threads', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ a {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-post-avatar i {
|
||||||
|
font-size: 4.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bb-post-avatar img {
|
.bb-post-avatar img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -263,6 +267,17 @@ a {
|
|||||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||||
width: fit-content;
|
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 {
|
.bb-post-author-meta {
|
||||||
@@ -317,6 +332,12 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-topic {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-actions {
|
.bb-post-actions {
|
||||||
@@ -847,6 +868,13 @@ a {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-portal-logo-link {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bb-portal-logo-image {
|
.bb-portal-logo-image {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -1665,6 +1693,101 @@ a {
|
|||||||
justify-content: flex-end;
|
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 {
|
.bb-drag-handle {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import {
|
|||||||
deleteForum,
|
deleteForum,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
listAllForums,
|
listAllForums,
|
||||||
|
listRanks,
|
||||||
listUsers,
|
listUsers,
|
||||||
reorderForums,
|
reorderForums,
|
||||||
saveSetting,
|
saveSetting,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
createRank,
|
||||||
|
deleteRank,
|
||||||
|
updateUserRank,
|
||||||
|
updateRank,
|
||||||
|
updateUser,
|
||||||
|
uploadRankBadgeImage,
|
||||||
uploadFavicon,
|
uploadFavicon,
|
||||||
uploadLogo,
|
uploadLogo,
|
||||||
updateForum,
|
updateForum,
|
||||||
@@ -28,10 +35,33 @@ export default function Acp({ isAdmin }) {
|
|||||||
const pendingOrder = useRef(null)
|
const pendingOrder = useRef(null)
|
||||||
const [createType, setCreateType] = useState(null)
|
const [createType, setCreateType] = useState(null)
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
|
const [userSearch, setUserSearch] = useState('')
|
||||||
const [usersLoading, setUsersLoading] = useState(false)
|
const [usersLoading, setUsersLoading] = useState(false)
|
||||||
const [usersError, setUsersError] = useState('')
|
const [usersError, setUsersError] = useState('')
|
||||||
const [usersPage, setUsersPage] = useState(1)
|
const [usersPage, setUsersPage] = useState(1)
|
||||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
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 [generalSaving, setGeneralSaving] = useState(false)
|
||||||
const [generalUploading, setGeneralUploading] = useState(false)
|
const [generalUploading, setGeneralUploading] = useState(false)
|
||||||
const [generalError, setGeneralError] = useState('')
|
const [generalError, setGeneralError] = useState('')
|
||||||
@@ -341,17 +371,93 @@ export default function Acp({ isAdmin }) {
|
|||||||
|
|
||||||
return () => observer.disconnect()
|
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 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,
|
selector: (row) => row.name,
|
||||||
sortable: true,
|
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'),
|
id: 'rank',
|
||||||
selector: (row) => row.email,
|
name: (
|
||||||
|
<span className="bb-sort-label">
|
||||||
|
{t('user.rank')}
|
||||||
|
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
width: '220px',
|
||||||
sortable: true,
|
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: '',
|
name: '',
|
||||||
@@ -370,7 +476,16 @@ export default function Acp({ isAdmin }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
title={t('user.edit')}
|
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" />
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -385,8 +500,9 @@ export default function Acp({ isAdmin }) {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
[t]
|
},
|
||||||
|
[t, ranks, ranksLoading, rankUpdatingId, userSort]
|
||||||
)
|
)
|
||||||
const userTableStyles = useMemo(
|
const userTableStyles = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -536,6 +652,57 @@ export default function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}, [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) => {
|
const getParentId = (forum) => {
|
||||||
if (!forum.parent) return null
|
if (!forum.parent) return null
|
||||||
if (typeof forum.parent === 'string') {
|
if (typeof forum.parent === 'string') {
|
||||||
@@ -1298,17 +1465,23 @@ export default function Acp({ isAdmin }) {
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="users" title={t('acp.users')}>
|
<Tab eventKey="users" title={t('acp.users')}>
|
||||||
{usersError && <p className="text-danger">{usersError}</p>}
|
{usersError && <p className="text-danger">{usersError}</p>}
|
||||||
|
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||||
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||||
{!usersLoading && (
|
{!usersLoading && (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={userColumns}
|
columns={userColumns}
|
||||||
data={users}
|
data={filteredUsers}
|
||||||
pagination
|
pagination
|
||||||
striped
|
striped
|
||||||
highlightOnHover={themeMode !== 'dark'}
|
highlightOnHover={themeMode !== 'dark'}
|
||||||
dense
|
dense
|
||||||
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
||||||
customStyles={userTableStyles}
|
customStyles={userTableStyles}
|
||||||
|
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
|
||||||
|
defaultSortFieldId="name"
|
||||||
|
onSort={(column, direction) => {
|
||||||
|
setUserSort({ columnId: column.id, direction })
|
||||||
|
}}
|
||||||
paginationComponentOptions={{
|
paginationComponentOptions={{
|
||||||
rowsPerPageText: t('table.rows_per_page'),
|
rowsPerPageText: t('table.rows_per_page'),
|
||||||
rangeSeparatorText: t('table.range_separator'),
|
rangeSeparatorText: t('table.range_separator'),
|
||||||
@@ -1320,7 +1493,147 @@ export default function Acp({ isAdmin }) {
|
|||||||
setUsersPage(1)
|
setUsersPage(1)
|
||||||
}}
|
}}
|
||||||
paginationComponent={UsersPagination}
|
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>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -1410,6 +1723,221 @@ export default function Acp({ isAdmin }) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ export default function ThreadView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const replyCount = posts.length
|
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(() => {
|
const allPosts = useMemo(() => {
|
||||||
if (!thread) return posts
|
if (!thread) return posts
|
||||||
const rootPost = {
|
const rootPost = {
|
||||||
@@ -53,6 +62,12 @@ export default function ThreadView() {
|
|||||||
created_at: thread.created_at,
|
created_at: thread.created_at,
|
||||||
user_name: thread.user_name,
|
user_name: thread.user_name,
|
||||||
user_avatar_url: thread.user_avatar_url,
|
user_avatar_url: thread.user_avatar_url,
|
||||||
|
user_posts_count: thread.user_posts_count,
|
||||||
|
user_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,
|
isRoot: true,
|
||||||
}
|
}
|
||||||
return [rootPost, ...posts]
|
return [rootPost, ...posts]
|
||||||
@@ -107,11 +122,17 @@ export default function ThreadView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bb-posts">
|
<div className="bb-posts">
|
||||||
{allPosts.map((post) => {
|
{allPosts.map((post, index) => {
|
||||||
const authorName = post.author?.username
|
const authorName = post.author?.username
|
||||||
|| post.user_name
|
|| post.user_name
|
||||||
|| post.author_name
|
|| post.author_name
|
||||||
|| t('thread.anonymous')
|
|| t('thread.anonymous')
|
||||||
|
const topicLabel = thread?.title
|
||||||
|
? post.isRoot
|
||||||
|
? thread.title
|
||||||
|
: `${t('thread.reply_prefix')} ${thread.title}`
|
||||||
|
: ''
|
||||||
|
const postNumber = index + 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bb-post-row" key={post.id}>
|
<article className="bb-post-row" key={post.id}>
|
||||||
@@ -124,16 +145,30 @@ export default function ThreadView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-name">{authorName}</div>
|
<div className="bb-post-author-name">{authorName}</div>
|
||||||
<div className="bb-post-author-role">Operator</div>
|
<div className="bb-post-author-role">
|
||||||
<div className="bb-post-author-badge">TEAM-RHF</div>
|
{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-meta">
|
||||||
<div className="bb-post-author-stat">
|
<div className="bb-post-author-stat">
|
||||||
<span className="bb-post-author-label">Posts:</span>
|
<span className="bb-post-author-label">{t('thread.posts')}:</span>
|
||||||
<span className="bb-post-author-value">63899</span>
|
<span className="bb-post-author-value">
|
||||||
|
{post.user_posts_count ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-stat">
|
<div className="bb-post-author-stat">
|
||||||
<span className="bb-post-author-label">Registered:</span>
|
<span className="bb-post-author-label">{t('thread.registered')}:</span>
|
||||||
<span className="bb-post-author-value">18.08.2004 18:50:03</span>
|
<span className="bb-post-author-value">
|
||||||
|
{formatDate(post.user_created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-stat">
|
<div className="bb-post-author-stat">
|
||||||
<span className="bb-post-author-label">Location:</span>
|
<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-content">
|
||||||
<div className="bb-post-header">
|
<div className="bb-post-header">
|
||||||
<div className="bb-post-header-meta">
|
<div className="bb-post-header-meta">
|
||||||
|
{topicLabel && (
|
||||||
|
<span className="bb-post-topic">
|
||||||
|
#{postNumber} {topicLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span>{t('thread.by')} {authorName}</span>
|
<span>{t('thread.by')} {authorName}</span>
|
||||||
{post.created_at && (
|
{post.created_at && (
|
||||||
<span>{post.created_at.slice(0, 10)}</span>
|
<span>{post.created_at.slice(0, 10)}</span>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"acp.show_header_name": "Forenname im Header anzeigen",
|
"acp.show_header_name": "Forenname im Header anzeigen",
|
||||||
"acp.add_category": "Kategorie hinzufügen",
|
"acp.add_category": "Kategorie hinzufügen",
|
||||||
"acp.add_forum": "Forum hinzufügen",
|
"acp.add_forum": "Forum hinzufügen",
|
||||||
|
"acp.ranks": "Ränge",
|
||||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||||
"acp.forums_tree": "Forenbaum",
|
"acp.forums_tree": "Forenbaum",
|
||||||
"acp.forums_type": "Typ",
|
"acp.forums_type": "Typ",
|
||||||
@@ -101,6 +102,22 @@
|
|||||||
"user.id": "ID",
|
"user.id": "ID",
|
||||||
"user.name": "Name",
|
"user.name": "Name",
|
||||||
"user.email": "E-Mail",
|
"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.roles": "Rollen",
|
||||||
"user.actions": "Aktionen",
|
"user.actions": "Aktionen",
|
||||||
"user.impersonate": "Imitieren",
|
"user.impersonate": "Imitieren",
|
||||||
@@ -173,6 +190,9 @@
|
|||||||
"thread.label": "Thread",
|
"thread.label": "Thread",
|
||||||
"thread.loading": "Thread wird geladen...",
|
"thread.loading": "Thread wird geladen...",
|
||||||
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
||||||
|
"thread.posts": "Beiträge",
|
||||||
|
"thread.reply_prefix": "Aw:",
|
||||||
|
"thread.registered": "Registriert",
|
||||||
"thread.replies": "Antworten",
|
"thread.replies": "Antworten",
|
||||||
"thread.views": "Zugriffe",
|
"thread.views": "Zugriffe",
|
||||||
"thread.last_post": "Letzter Beitrag",
|
"thread.last_post": "Letzter Beitrag",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"acp.show_header_name": "Display name in header",
|
"acp.show_header_name": "Display name in header",
|
||||||
"acp.add_category": "Add category",
|
"acp.add_category": "Add category",
|
||||||
"acp.add_forum": "Add forum",
|
"acp.add_forum": "Add forum",
|
||||||
|
"acp.ranks": "Ranks",
|
||||||
"acp.forums_parent_root": "Root (no parent)",
|
"acp.forums_parent_root": "Root (no parent)",
|
||||||
"acp.forums_tree": "Forum tree",
|
"acp.forums_tree": "Forum tree",
|
||||||
"acp.forums_type": "Type",
|
"acp.forums_type": "Type",
|
||||||
@@ -101,6 +102,22 @@
|
|||||||
"user.id": "ID",
|
"user.id": "ID",
|
||||||
"user.name": "Name",
|
"user.name": "Name",
|
||||||
"user.email": "Email",
|
"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.roles": "Roles",
|
||||||
"user.actions": "Actions",
|
"user.actions": "Actions",
|
||||||
"user.impersonate": "Impersonate",
|
"user.impersonate": "Impersonate",
|
||||||
@@ -173,6 +190,9 @@
|
|||||||
"thread.label": "Thread",
|
"thread.label": "Thread",
|
||||||
"thread.loading": "Loading thread...",
|
"thread.loading": "Loading thread...",
|
||||||
"thread.login_hint": "Log in to reply to this 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.replies": "Replies",
|
||||||
"thread.views": "Views",
|
"thread.views": "Views",
|
||||||
"thread.last_post": "Last post",
|
"thread.last_post": "Last post",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\UploadController;
|
|||||||
use App\Http\Controllers\UserSettingController;
|
use App\Http\Controllers\UserSettingController;
|
||||||
use App\Http\Controllers\UserController;
|
use App\Http\Controllers\UserController;
|
||||||
use App\Http\Controllers\VersionController;
|
use App\Http\Controllers\VersionController;
|
||||||
|
use App\Http\Controllers\RankController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/login', [AuthController::class, 'login']);
|
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::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
|
||||||
Route::get('/i18n/{locale}', I18nController::class);
|
Route::get('/i18n/{locale}', I18nController::class);
|
||||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
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/me', [UserController::class, 'me'])->middleware('auth:sanctum');
|
||||||
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
|
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
|
||||||
|
Route::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', [ForumController::class, 'index']);
|
||||||
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
||||||
|
|||||||
Reference in New Issue
Block a user