diff --git a/CHANGELOG.md b/CHANGELOG.md index 949519a..916dbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 374c208..cfea5cd 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -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(), ]; diff --git a/app/Http/Controllers/RankController.php b/app/Http/Controllers/RankController.php new file mode 100644 index 0000000..b69c573 --- /dev/null +++ b/app/Http/Controllers/RankController.php @@ -0,0 +1,153 @@ +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), + ]); + } +} diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index b7b51d5..89c232a 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -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(), ]; diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 0a69cfb..6f55045 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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) { diff --git a/app/Models/Rank.php b/app/Models/Rank.php new file mode 100644 index 0000000..579ce38 --- /dev/null +++ b/app/Models/Rank.php @@ -0,0 +1,28 @@ + $users + */ +class Rank extends Model +{ + protected $fillable = [ + 'name', + 'badge_type', + 'badge_text', + 'badge_image_path', + ]; + + public function users(): HasMany + { + return $this->hasMany(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f2de4ff..a80c99b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); + } } diff --git a/database/migrations/2026_01_05_020000_create_ranks_table.php b/database/migrations/2026_01_05_020000_create_ranks_table.php new file mode 100644 index 0000000..c2e9a06 --- /dev/null +++ b/database/migrations/2026_01_05_020000_create_ranks_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ranks'); + } +}; diff --git a/database/migrations/2026_01_05_020100_add_rank_id_to_users_table.php b/database/migrations/2026_01_05_020100_add_rank_id_to_users_table.php new file mode 100644 index 0000000..eba72cc --- /dev/null +++ b/database/migrations/2026_01_05_020100_add_rank_id_to_users_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_01_05_020200_add_badge_fields_to_ranks_table.php b/database/migrations/2026_01_05_020200_add_badge_fields_to_ranks_table.php new file mode 100644 index 0000000..13636ba --- /dev/null +++ b/database/migrations/2026_01_05_020200_add_badge_fields_to_ranks_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c518525..1befdb1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder { $this->call([ RoleSeeder::class, + RankSeeder::class, UserSeeder::class, ForumSeeder::class, ThreadSeeder::class, diff --git a/database/seeders/RankSeeder.php b/database/seeders/RankSeeder.php new file mode 100644 index 0000000..adc9b1e --- /dev/null +++ b/database/seeders/RankSeeder.php @@ -0,0 +1,41 @@ + '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]); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 838c8b4..3bf8c29 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -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(), ] diff --git a/resources/js/App.jsx b/resources/js/App.jsx index d2087d3..fcf8e6d 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -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({
- {logoUrl && ( - {forumName - )} - {(showHeaderName || !logoUrl) && ( -
{forumName || '24unix.net'}
- )} + + {logoUrl && ( + {forumName + )} + {(showHeaderName || !logoUrl) && ( +
{forumName || '24unix.net'}
+ )} +
diff --git a/resources/js/api/client.js b/resources/js/api/client.js index c5193ee..39cffba 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -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', diff --git a/resources/js/index.css b/resources/js/index.css index 409a8f9..74f633c 100644 --- a/resources/js/index.css +++ b/resources/js/index.css @@ -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; diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index dd20cb0..1a641e3 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -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: ( + + {t('user.name')} +