Compare commits
4 Commits
c8d2bd508e
...
fd29b928d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd29b928d8 | ||
|
|
98094459e3 | ||
|
|
3bb2946656 | ||
|
|
bbbf8eb6c1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
._*
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,13 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-12
|
||||||
|
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
||||||
|
- Added username-or-email login with case-insensitive unique usernames.
|
||||||
|
- 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.
|
||||||
|
- Added phpBB-style post action buttons and post author info for replies.
|
||||||
|
|
||||||
## 2026-01-02
|
## 2026-01-02
|
||||||
- Added ACP general settings for forum name, theme, accents, and logo (no reload required).
|
- Added ACP general settings for forum name, theme, accents, and logo (no reload required).
|
||||||
- Added admin-only upload endpoints and ACP UI for logos and favicons.
|
- Added admin-only upload endpoints and ACP UI for logos and favicons.
|
||||||
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
|
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
|
||||||
|
|
||||||
## 2026-01-11
|
|
||||||
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
|
||||||
|
|
||||||
## 2025-12-30
|
## 2025-12-30
|
||||||
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
|
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
|
||||||
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
|
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -19,8 +20,16 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
Validator::make(data: $input, rules: [
|
Validator::make(data: $input, rules: [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(table: User::class, column: 'name_canonical'),
|
||||||
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -33,6 +42,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
|
|
||||||
return User::create(attributes: [
|
return User::create(attributes: [
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => Hash::make(value: $input['password']),
|
'password' => Hash::make(value: $input['password']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'name_canonical')->ignore($user->id),
|
||||||
|
],
|
||||||
|
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -34,6 +43,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
} else {
|
} else {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
@@ -48,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
{
|
{
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'email_verified_at' => null,
|
'email_verified_at' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|||||||
@@ -3,14 +3,22 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Fortify\CreateNewUser;
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\PasswordValidationRules;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||||
{
|
{
|
||||||
$input = [
|
$input = [
|
||||||
@@ -33,16 +41,30 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$request->merge([
|
||||||
|
'login' => $request->input('login', $request->input('email')),
|
||||||
|
]);
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'email' => ['required', 'email'],
|
'login' => ['required', 'string'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::where('email', $request->input('email'))->first();
|
$login = trim((string) $request->input('login'));
|
||||||
|
$loginNormalized = Str::lower($login);
|
||||||
|
$userQuery = User::query();
|
||||||
|
|
||||||
|
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$userQuery->whereRaw('lower(email) = ?', [$loginNormalized]);
|
||||||
|
} else {
|
||||||
|
$userQuery->where('name_canonical', $loginNormalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $userQuery->first();
|
||||||
|
|
||||||
if (!$user || !Hash::check($request->input('password'), $user->password)) {
|
if (!$user || !Hash::check($request->input('password'), $user->password)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => ['Invalid credentials.'],
|
'login' => ['Invalid credentials.'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +84,93 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
if (!hash_equals($hash, sha1($user->getEmailForVerification()))) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->hasVerifiedEmail()) {
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
event(new Verified($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only('email')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status !== Password::RESET_LINK_SENT) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__($status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['message' => __($status)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => ['required'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function (User $user, string $password) {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status !== Password::PASSWORD_RESET) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__($status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['message' => __($status)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'current_password' => ['required'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (!$user || !Hash::check($request->input('current_password'), $user->password)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'current_password' => ['Invalid current password.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($request->input('password')),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Password updated.']);
|
||||||
|
}
|
||||||
|
|
||||||
public function logout(Request $request): JsonResponse
|
public function logout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->user()?->currentAccessToken()?->delete();
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ class ForumController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$position = Forum::where('parent_id', $parentId)->max('position');
|
if ($parentId === null) {
|
||||||
|
Forum::whereNull('parent_id')->increment('position');
|
||||||
|
$position = 0;
|
||||||
|
} else {
|
||||||
|
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||||
|
}
|
||||||
|
|
||||||
$forum = Forum::create([
|
$forum = Forum::create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ use App\Models\Post;
|
|||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class PostController extends Controller
|
class PostController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Post::query()->withoutTrashed();
|
$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)) {
|
||||||
@@ -45,6 +48,10 @@ class PostController extends Controller
|
|||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$post->loadMissing([
|
||||||
|
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json($this->serializePost($post), 201);
|
return response()->json($this->serializePost($post), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +88,18 @@ class PostController extends Controller
|
|||||||
'body' => $post->body,
|
'body' => $post->body,
|
||||||
'thread' => "/api/threads/{$post->thread_id}",
|
'thread' => "/api/threads/{$post->thread_id}",
|
||||||
'user_id' => $post->user_id,
|
'user_id' => $post->user_id,
|
||||||
|
'user_name' => $post->user?->name,
|
||||||
|
'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(),
|
'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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ use App\Models\Forum;
|
|||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ThreadController extends Controller
|
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)) {
|
||||||
@@ -31,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +100,17 @@ class ThreadController extends Controller
|
|||||||
'forum' => "/api/forums/{$thread->forum_id}",
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
'user_id' => $thread->user_id,
|
'user_id' => $thread->user_id,
|
||||||
'user_name' => $thread->user?->name,
|
'user_name' => $thread->user?->name,
|
||||||
|
'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(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,37 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class UploadController extends Controller
|
class UploadController extends Controller
|
||||||
{
|
{
|
||||||
|
public function storeAvatar(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => [
|
||||||
|
'required',
|
||||||
|
'image',
|
||||||
|
'mimes:jpg,jpeg,png,gif,webp',
|
||||||
|
'max:2048',
|
||||||
|
'dimensions:max_width=150,max_height=150',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($user->avatar_path) {
|
||||||
|
Storage::disk('public')->delete($user->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $data['file']->store('avatars', 'public');
|
||||||
|
$user->avatar_path = $path;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'path' => $path,
|
||||||
|
'url' => Storage::url($path),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function storeLogo(Request $request): JsonResponse
|
public function storeLogo(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|||||||
@@ -4,22 +4,155 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
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
|
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) => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'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(),
|
'roles' => $user->roles->pluck('name')->values(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($users);
|
return response()->json($users);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Storage::url($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;
|
||||||
@@ -64,6 +65,9 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'name_canonical',
|
||||||
|
'avatar_path',
|
||||||
|
'rank_id',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
];
|
];
|
||||||
@@ -95,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
artisan
4
artisan
@@ -4,7 +4,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
define(constant_name: 'LARAVEL_START', value: microtime(as_float: true));
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
// Register the Composer autoloader...
|
||||||
require __DIR__.'/vendor/autoload.php';
|
require __DIR__.'/vendor/autoload.php';
|
||||||
@@ -13,6 +13,6 @@ require __DIR__.'/vendor/autoload.php';
|
|||||||
/** @var Application $app */
|
/** @var Application $app */
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
$status = $app->handleCommand(new ArgvInput);
|
$status = $app->handleCommand(input: new ArgvInput);
|
||||||
|
|
||||||
exit($status);
|
exit($status);
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ class UserFactory extends Factory
|
|||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
|
$name = fake()->unique()->userName();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => $name,
|
||||||
|
'name_canonical' => Str::lower($name),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('name_canonical')->nullable()->after('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('name_canonical')
|
||||||
|
->update(['name_canonical' => DB::raw('lower(name)')]);
|
||||||
|
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->unique('name_canonical');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['name_canonical']);
|
||||||
|
$table->dropColumn('name_canonical');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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->string('avatar_path')->nullable()->after('name_canonical');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('avatar_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ 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 App\Models\Rank;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
@@ -14,14 +16,29 @@ class UserSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$adminRole = Role::where('name', 'ROLE_ADMIN')->first();
|
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
|
||||||
$userRole = Role::where('name', '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::firstOrCreate(
|
$admin = User::updateOrCreate(
|
||||||
['email' => 'tracer@24unix.net'],
|
attributes: ['email' => 'tracer@24unix.net'],
|
||||||
[
|
values : [
|
||||||
'name' => 'tracer',
|
'name' => 'tracer',
|
||||||
'password' => Hash::make('password'),
|
'name_canonical' => Str::lower('tracer'),
|
||||||
|
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
|
||||||
|
'password' => Hash::make(value: 'password'),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$micha = User::updateOrCreate(
|
||||||
|
attributes: ['email' => 'micha@24unix.net'],
|
||||||
|
values : [
|
||||||
|
'name' => 'Micha',
|
||||||
|
'name_canonical' => Str::lower('Micha'),
|
||||||
|
'rank_id' => $memberRank?->id,
|
||||||
|
'password' => Hash::make(value: 'password'),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -34,6 +51,10 @@ class UserSeeder extends Seeder
|
|||||||
$admin->roles()->syncWithoutDetaching([$userRole->id]);
|
$admin->roles()->syncWithoutDetaching([$userRole->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($userRole) {
|
||||||
|
$micha->roles()->syncWithoutDetaching([$userRole->id]);
|
||||||
|
}
|
||||||
|
|
||||||
$users = User::factory()->count(100)->create([
|
$users = User::factory()->count(100)->create([
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import Register from './pages/Register'
|
|||||||
import Acp from './pages/Acp'
|
import Acp from './pages/Acp'
|
||||||
import BoardIndex from './pages/BoardIndex'
|
import BoardIndex from './pages/BoardIndex'
|
||||||
import Ucp from './pages/Ucp'
|
import Ucp from './pages/Ucp'
|
||||||
|
import Profile from './pages/Profile'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
||||||
|
|
||||||
function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) {
|
function PortalHeader({
|
||||||
|
userMenu,
|
||||||
|
isAuthenticated,
|
||||||
|
forumName,
|
||||||
|
logoUrl,
|
||||||
|
showHeaderName,
|
||||||
|
canAccessAcp,
|
||||||
|
canAccessMcp,
|
||||||
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [crumbs, setCrumbs] = useState([])
|
const [crumbs, setCrumbs] = useState([])
|
||||||
@@ -96,6 +105,15 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +125,17 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
}, [location.pathname, t])
|
}, [location.pathname, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container 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">
|
||||||
{logoUrl && (
|
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
{logoUrl && (
|
||||||
)}
|
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||||
{(showHeaderName || !logoUrl) && (
|
)}
|
||||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
{(showHeaderName || !logoUrl) && (
|
||||||
)}
|
<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 />
|
||||||
@@ -135,12 +155,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
<span>
|
<span>
|
||||||
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
|
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
|
||||||
</span>
|
</span>
|
||||||
<Link to="/acp" className="bb-portal-link">
|
{isAuthenticated && canAccessAcp && (
|
||||||
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
<>
|
||||||
</Link>
|
<Link to="/acp" className="bb-portal-link">
|
||||||
<span>
|
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
||||||
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
|
</Link>
|
||||||
</span>
|
</>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && canAccessMcp && (
|
||||||
|
<span>
|
||||||
|
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +223,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { token, email, logout, isAdmin } = useAuth()
|
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
|
||||||
const [versionInfo, setVersionInfo] = useState(null)
|
const [versionInfo, setVersionInfo] = useState(null)
|
||||||
const [theme, setTheme] = useState('auto')
|
const [theme, setTheme] = useState('auto')
|
||||||
const [resolvedTheme, setResolvedTheme] = useState('light')
|
const [resolvedTheme, setResolvedTheme] = useState('light')
|
||||||
@@ -403,7 +429,7 @@ function AppShell() {
|
|||||||
<NavDropdown.Item as={Link} to="/ucp">
|
<NavDropdown.Item as={Link} to="/ucp">
|
||||||
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
|
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
<NavDropdown.Item as={Link} to="/ucp">
|
<NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
|
||||||
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
|
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
@@ -413,6 +439,8 @@ function AppShell() {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
canAccessAcp={isAdmin}
|
||||||
|
canAccessMcp={isModerator}
|
||||||
/>
|
/>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
@@ -421,6 +449,7 @@ function AppShell() {
|
|||||||
<Route path="/thread/:id" element={<ThreadView />} />
|
<Route path="/thread/:id" element={<ThreadView />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/profile/:id" element={<Profile />} />
|
||||||
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/ucp"
|
path="/ucp"
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ export async function getCollection(path) {
|
|||||||
return data?.['hydra:member'] || []
|
return data?.['hydra:member'] || []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email, password) {
|
export async function login(login, password) {
|
||||||
return apiFetch('/login', {
|
return apiFetch('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ login, password }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +70,23 @@ export async function listAllForums() {
|
|||||||
return getCollection('/forums?pagination=false')
|
return getCollection('/forums?pagination=false')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
return apiFetch('/user/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatar(file) {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('file', file)
|
||||||
|
return apiFetch('/user/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserProfile(id) {
|
||||||
|
return apiFetch(`/user/profile/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchVersion() {
|
export async function fetchVersion() {
|
||||||
return apiFetch('/version')
|
return apiFetch('/version')
|
||||||
}
|
}
|
||||||
@@ -211,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',
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
|
|||||||
userId: effectiveUserId,
|
userId: effectiveUserId,
|
||||||
roles: effectiveRoles,
|
roles: effectiveRoles,
|
||||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
async login(emailInput, password) {
|
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
const data = await apiLogin(emailInput, password)
|
async login(loginInput, password) {
|
||||||
|
const data = await apiLogin(loginInput, password)
|
||||||
localStorage.setItem('speedbb_token', data.token)
|
localStorage.setItem('speedbb_token', data.token)
|
||||||
localStorage.setItem('speedbb_email', data.email || emailInput)
|
localStorage.setItem('speedbb_email', data.email || loginInput)
|
||||||
if (data.user_id) {
|
if (data.user_id) {
|
||||||
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
||||||
setUserId(String(data.user_id))
|
setUserId(String(data.user_id))
|
||||||
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
|
|||||||
setRoles([])
|
setRoles([])
|
||||||
}
|
}
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setEmail(data.email || emailInput)
|
setEmail(data.email || loginInput)
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
localStorage.removeItem('speedbb_token')
|
localStorage.removeItem('speedbb_token')
|
||||||
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
|
|||||||
userId: effectiveUserId,
|
userId: effectiveUserId,
|
||||||
roles: effectiveRoles,
|
roles: effectiveRoles,
|
||||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
|
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
hasToken: Boolean(token),
|
hasToken: Boolean(token),
|
||||||
})
|
})
|
||||||
}, [email, effectiveUserId, effectiveRoles, token])
|
}, [email, effectiveUserId, effectiveRoles, token])
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
--bb-gold: #e4a634;
|
--bb-gold: #e4a634;
|
||||||
--bb-peach: #f4c7a3;
|
--bb-peach: #f4c7a3;
|
||||||
--bb-border: #e0d7c7;
|
--bb-border: #e0d7c7;
|
||||||
|
--bb-shell-max: 1880px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -200,7 +201,7 @@ a {
|
|||||||
|
|
||||||
.bb-post-row {
|
.bb-post-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
border-top: 1px solid var(--bb-border);
|
border-top: 1px solid var(--bb-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,15 +219,26 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-avatar {
|
.bb-post-avatar {
|
||||||
width: 44px;
|
width: 150px;
|
||||||
height: 44px;
|
height: 150px;
|
||||||
border-radius: 50%;
|
border-radius: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-accent, #f29b3f);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-avatar i {
|
||||||
|
font-size: 4.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-author-name {
|
.bb-post-author-name {
|
||||||
@@ -234,9 +246,73 @@ a {
|
|||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-accent, #f29b3f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-post-author-role {
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: -0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
|
||||||
|
color: #7b1f2a;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
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 {
|
.bb-post-author-meta {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--bb-ink-muted);
|
color: var(--bb-ink-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-label {
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-value {
|
||||||
|
color: var(--bb-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-value i {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-author-contact .bb-post-author-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-content {
|
.bb-post-content {
|
||||||
@@ -252,6 +328,44 @@ a {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-post-header-meta {
|
||||||
|
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 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-post-action {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #2a2f3a;
|
||||||
|
background: #20252f;
|
||||||
|
color: #c7cdd7;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.bb-post-action:hover {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
border-color: var(--bb-accent, #f29b3f);
|
||||||
|
}
|
||||||
|
|
||||||
.bb-post-body {
|
.bb-post-body {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: var(--bb-ink);
|
color: var(--bb-ink);
|
||||||
@@ -290,6 +404,11 @@ a {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-post-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-forum-row {
|
.bb-forum-row {
|
||||||
@@ -717,8 +836,9 @@ a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-shell {
|
.container.bb-portal-shell,
|
||||||
max-width: 1400px;
|
.container.bb-shell-container {
|
||||||
|
max-width: var(--bb-shell-max);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-banner {
|
.bb-portal-banner {
|
||||||
@@ -748,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;
|
||||||
@@ -1157,6 +1284,56 @@ a {
|
|||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-avatar-preview {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-size: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-avatar-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-profile {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-profile-avatar {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-size: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-profile-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-profile-name {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bb-ink);
|
||||||
|
}
|
||||||
|
|
||||||
.bb-portal-list {
|
.bb-portal-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1273,11 +1450,24 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-user-avatar {
|
.bb-portal-user-avatar {
|
||||||
width: 72px;
|
width: 150px;
|
||||||
height: 72px;
|
height: 150px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.04));
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.04));
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
font-size: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-portal-user-avatar img {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 150px;
|
||||||
|
max-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-user-name {
|
.bb-portal-user-name {
|
||||||
@@ -1495,6 +1685,109 @@ a {
|
|||||||
border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
|
border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-tree-action-group {
|
||||||
|
width: 176px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-tree-action-group .bb-action-group {
|
||||||
|
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') {
|
||||||
@@ -905,32 +1072,34 @@ export default function Acp({ isAdmin }) {
|
|||||||
>
|
>
|
||||||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<ButtonGroup size="sm" className="bb-action-group">
|
<div className="bb-tree-action-group">
|
||||||
{node.type === 'category' && (
|
<ButtonGroup size="sm" className="bb-action-group w-100">
|
||||||
<>
|
{node.type === 'category' && (
|
||||||
<Button
|
<>
|
||||||
variant="dark"
|
<Button
|
||||||
onClick={() => handleStartCreateChild('category', node.id)}
|
variant="dark"
|
||||||
title={t('acp.add_category')}
|
onClick={() => handleStartCreateChild('category', node.id)}
|
||||||
>
|
title={t('acp.add_category')}
|
||||||
<i className="bi bi-folder-plus" aria-hidden="true" />
|
>
|
||||||
</Button>
|
<i className="bi bi-folder-plus" aria-hidden="true" />
|
||||||
<Button
|
</Button>
|
||||||
variant="dark"
|
<Button
|
||||||
onClick={() => handleStartCreateChild('forum', node.id)}
|
variant="dark"
|
||||||
title={t('acp.add_forum')}
|
onClick={() => handleStartCreateChild('forum', node.id)}
|
||||||
>
|
title={t('acp.add_forum')}
|
||||||
<i className="bi bi-chat-left-text" aria-hidden="true" />
|
>
|
||||||
</Button>
|
<i className="bi bi-chat-left-text" aria-hidden="true" />
|
||||||
</>
|
</Button>
|
||||||
)}
|
</>
|
||||||
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
|
)}
|
||||||
<i className="bi bi-pencil" aria-hidden="true" />
|
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
|
||||||
</Button>
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
<Button variant="dark" onClick={() => handleDelete(node.id)} title={t('acp.delete')}>
|
</Button>
|
||||||
<i className="bi bi-trash" aria-hidden="true" />
|
<Button variant="dark" onClick={() => handleDelete(node.id)} title={t('acp.delete')}>
|
||||||
</Button>
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
</ButtonGroup>
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{node.children?.length > 0 &&
|
{node.children?.length > 0 &&
|
||||||
@@ -942,7 +1111,7 @@ export default function Acp({ isAdmin }) {
|
|||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5">
|
||||||
<h2 className="mb-3">{t('acp.title')}</h2>
|
<h2 className="mb-3">{t('acp.title')}</h2>
|
||||||
<p className="bb-muted">{t('acp.no_access')}</p>
|
<p className="bb-muted">{t('acp.no_access')}</p>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -1296,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'),
|
||||||
@@ -1318,9 +1493,149 @@ 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>
|
||||||
|
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||||
|
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||||
|
<Row className="g-3 align-items-end mb-3">
|
||||||
|
<Col md={6}>
|
||||||
|
<Form onSubmit={handleCreateRank}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('rank.name')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={rankFormName}
|
||||||
|
onChange={(event) => setRankFormName(event.target.value)}
|
||||||
|
placeholder={t('rank.name_placeholder')}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||||
|
<div className="d-flex gap-3">
|
||||||
|
<Form.Check
|
||||||
|
type="radio"
|
||||||
|
id="rank-badge-text"
|
||||||
|
name="rankBadgeType"
|
||||||
|
label={t('rank.badge_text')}
|
||||||
|
checked={rankFormType === 'text'}
|
||||||
|
onChange={() => setRankFormType('text')}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="radio"
|
||||||
|
id="rank-badge-image"
|
||||||
|
name="rankBadgeType"
|
||||||
|
label={t('rank.badge_image')}
|
||||||
|
checked={rankFormType === 'image'}
|
||||||
|
onChange={() => setRankFormType('image')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
{rankFormType === 'text' && (
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={rankFormText}
|
||||||
|
onChange={(event) => setRankFormText(event.target.value)}
|
||||||
|
placeholder={t('rank.badge_text_placeholder')}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
{rankFormType === 'image' && (
|
||||||
|
<Form.Group className="mt-3">
|
||||||
|
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||||
|
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||||
|
disabled={rankSaving}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Col>
|
||||||
|
<Col md="auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bb-accent-button"
|
||||||
|
onClick={handleCreateRank}
|
||||||
|
disabled={rankSaving || !rankFormName.trim()}
|
||||||
|
>
|
||||||
|
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||||
|
{!ranksLoading && ranks.length === 0 && (
|
||||||
|
<p className="bb-muted">{t('rank.empty')}</p>
|
||||||
|
)}
|
||||||
|
{!ranksLoading && ranks.length > 0 && (
|
||||||
|
<div className="bb-rank-list">
|
||||||
|
{ranks.map((rank) => (
|
||||||
|
<div key={rank.id} className="bb-rank-row">
|
||||||
|
<div className="bb-rank-main">
|
||||||
|
<span>{rank.name}</span>
|
||||||
|
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||||||
|
<img src={rank.badge_image_url} alt="" />
|
||||||
|
)}
|
||||||
|
{rank.badge_type !== 'image' && rank.badge_text && (
|
||||||
|
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bb-rank-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="dark"
|
||||||
|
onClick={() => {
|
||||||
|
setRankEdit({
|
||||||
|
id: rank.id,
|
||||||
|
name: rank.name,
|
||||||
|
badgeType: rank.badge_type || 'text',
|
||||||
|
badgeText: rank.badge_text || '',
|
||||||
|
badgeImageUrl: rank.badge_image_url || '',
|
||||||
|
})
|
||||||
|
setRankEditImage(null)
|
||||||
|
setShowRankModal(true)
|
||||||
|
setRanksError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="dark"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('rank.delete_confirm'))) return
|
||||||
|
setRankSaving(true)
|
||||||
|
setRanksError('')
|
||||||
|
try {
|
||||||
|
await deleteRank(rank.id)
|
||||||
|
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
|
||||||
|
} catch (err) {
|
||||||
|
setRanksError(err.message)
|
||||||
|
} finally {
|
||||||
|
setRankSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
||||||
<Modal.Header closeButton closeVariant="white">
|
<Modal.Header closeButton closeVariant="white">
|
||||||
@@ -1408,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export default function BoardIndex() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-4 bb-portal-shell">
|
<Container fluid className="py-4 bb-portal-shell">
|
||||||
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{!loading && forumTree.length === 0 && (
|
{!loading && forumTree.length === 0 && (
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function ForumView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5 bb-shell-container">
|
||||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{forum && (
|
{forum && (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Badge, Container } from 'react-bootstrap'
|
import { Badge, Container } from 'react-bootstrap'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listAllForums, listThreads } from '../api/client'
|
import { getCurrentUser, listAllForums, listThreads } from '../api/client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [forums, setForums] = useState([])
|
const [forums, setForums] = useState([])
|
||||||
@@ -10,6 +11,8 @@ export default function Home() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loadingForums, setLoadingForums] = useState(true)
|
const [loadingForums, setLoadingForums] = useState(true)
|
||||||
const [loadingThreads, setLoadingThreads] = useState(true)
|
const [loadingThreads, setLoadingThreads] = useState(true)
|
||||||
|
const [profile, setProfile] = useState(null)
|
||||||
|
const { token, roles, email } = useAuth()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,6 +29,27 @@ export default function Home() {
|
|||||||
.finally(() => setLoadingThreads(false))
|
.finally(() => setLoadingThreads(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setProfile(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
getCurrentUser()
|
||||||
|
.then((data) => {
|
||||||
|
if (!active) return
|
||||||
|
setProfile(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setProfile(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
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') {
|
||||||
@@ -79,6 +103,13 @@ export default function Home() {
|
|||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
}, [threads])
|
}, [threads])
|
||||||
|
|
||||||
|
const roleLabel = useMemo(() => {
|
||||||
|
if (!roles?.length) return t('portal.user_role_member')
|
||||||
|
if (roles.includes('ROLE_ADMIN')) return t('portal.user_role_operator')
|
||||||
|
if (roles.includes('ROLE_MODERATOR')) return t('portal.user_role_moderator')
|
||||||
|
return t('portal.user_role_member')
|
||||||
|
}, [roles, t])
|
||||||
|
|
||||||
const resolveForumName = (thread) => {
|
const resolveForumName = (thread) => {
|
||||||
if (!thread?.forum) return t('portal.unknown_forum')
|
if (!thread?.forum) return t('portal.unknown_forum')
|
||||||
const parts = thread.forum.split('/')
|
const parts = thread.forum.split('/')
|
||||||
@@ -118,7 +149,7 @@ export default function Home() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="pb-4 bb-portal-shell">
|
<Container fluid className="pb-4 bb-portal-shell">
|
||||||
<div className="bb-portal-layout">
|
<div className="bb-portal-layout">
|
||||||
<aside className="bb-portal-column bb-portal-column--left">
|
<aside className="bb-portal-column bb-portal-column--left">
|
||||||
<div className="bb-portal-card">
|
<div className="bb-portal-card">
|
||||||
@@ -205,9 +236,15 @@ export default function Home() {
|
|||||||
<div className="bb-portal-card">
|
<div className="bb-portal-card">
|
||||||
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
||||||
<div className="bb-portal-user-card">
|
<div className="bb-portal-user-card">
|
||||||
<div className="bb-portal-user-avatar" />
|
<div className="bb-portal-user-avatar">
|
||||||
<div className="bb-portal-user-name">tracer</div>
|
{profile?.avatar_url ? (
|
||||||
<div className="bb-portal-user-role">Operator</div>
|
<img src={profile.avatar_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<i className="bi bi-person" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bb-portal-user-name">{profile?.name || email || 'User'}</div>
|
||||||
|
<div className="bb-portal-user-role">{roleLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="bb-portal-list">
|
<ul className="bb-portal-list">
|
||||||
<li>{t('portal.user_new_posts')}</li>
|
<li>{t('portal.user_new_posts')}</li>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [email, setEmail] = useState('')
|
const [loginValue, setLoginValue] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -18,7 +18,7 @@ export default function Login() {
|
|||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(loginValue, password)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -28,7 +28,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5">
|
||||||
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
|
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
|
||||||
@@ -36,11 +36,12 @@ export default function Login() {
|
|||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('form.email')}</Form.Label>
|
<Form.Label>{t('auth.login_identifier')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="email"
|
type="text"
|
||||||
value={email}
|
value={loginValue}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setLoginValue(event.target.value)}
|
||||||
|
placeholder={t('auth.login_placeholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|||||||
65
resources/js/pages/Profile.jsx
Normal file
65
resources/js/pages/Profile.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Container } from 'react-bootstrap'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getUserProfile } from '../api/client'
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [profile, setProfile] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
getUserProfile(id)
|
||||||
|
.then((data) => {
|
||||||
|
if (!active) return
|
||||||
|
setProfile(data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!active) return
|
||||||
|
setError(err.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container fluid className="py-5 bb-portal-shell">
|
||||||
|
<div className="bb-portal-card">
|
||||||
|
<div className="bb-portal-card-title">{t('profile.title')}</div>
|
||||||
|
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
|
||||||
|
{error && <p className="text-danger">{error}</p>}
|
||||||
|
{profile && (
|
||||||
|
<div className="bb-profile">
|
||||||
|
<div className="bb-profile-avatar">
|
||||||
|
{profile.avatar_url ? (
|
||||||
|
<img src={profile.avatar_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<i className="bi bi-person" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bb-profile-meta">
|
||||||
|
<div className="bb-profile-name">{profile.name}</div>
|
||||||
|
{profile.created_at && (
|
||||||
|
<div className="bb-muted">
|
||||||
|
{t('profile.registered')} {profile.created_at.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5">
|
||||||
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
|
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -52,6 +61,13 @@ export default function ThreadView() {
|
|||||||
body: thread.body,
|
body: thread.body,
|
||||||
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_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]
|
||||||
@@ -64,7 +80,7 @@ export default function ThreadView() {
|
|||||||
const totalPosts = allPosts.length
|
const totalPosts = allPosts.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-4">
|
<Container fluid className="py-4 bb-shell-container">
|
||||||
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{thread && (
|
{thread && (
|
||||||
@@ -106,29 +122,104 @@ 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}>
|
||||||
<aside className="bb-post-author">
|
<aside className="bb-post-author">
|
||||||
<div className="bb-post-avatar">
|
<div className="bb-post-avatar">
|
||||||
<i className="bi bi-person" aria-hidden="true" />
|
{post.user_avatar_url ? (
|
||||||
|
<img src={post.user_avatar_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<i className="bi bi-person" aria-hidden="true" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-author-name">{authorName}</div>
|
<div className="bb-post-author-name">{authorName}</div>
|
||||||
|
<div className="bb-post-author-role">
|
||||||
|
{post.user_rank_name || ''}
|
||||||
|
</div>
|
||||||
|
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
|
||||||
|
<div className="bb-post-author-badge">
|
||||||
|
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
|
||||||
|
<img src={post.user_rank_badge_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{post.user_rank_badge_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="bb-post-author-meta">
|
<div className="bb-post-author-meta">
|
||||||
{post.isRoot ? t('thread.label') : t('thread.reply')}
|
<div className="bb-post-author-stat">
|
||||||
|
<span className="bb-post-author-label">{t('thread.posts')}:</span>
|
||||||
|
<span className="bb-post-author-value">
|
||||||
|
{post.user_posts_count ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-post-author-stat">
|
||||||
|
<span className="bb-post-author-label">{t('thread.registered')}:</span>
|
||||||
|
<span className="bb-post-author-value">
|
||||||
|
{formatDate(post.user_created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-post-author-stat">
|
||||||
|
<span className="bb-post-author-label">Location:</span>
|
||||||
|
<span className="bb-post-author-value">Kollmar</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-post-author-stat">
|
||||||
|
<span className="bb-post-author-label">Thanks given:</span>
|
||||||
|
<span className="bb-post-author-value">7</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-post-author-stat">
|
||||||
|
<span className="bb-post-author-label">Thanks received:</span>
|
||||||
|
<span className="bb-post-author-value">5</span>
|
||||||
|
</div>
|
||||||
|
<div className="bb-post-author-stat bb-post-author-contact">
|
||||||
|
<span className="bb-post-author-label">Contact:</span>
|
||||||
|
<span className="bb-post-author-value">
|
||||||
|
<i className="bi bi-chat-dots" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="bb-post-content">
|
<div className="bb-post-content">
|
||||||
<div className="bb-post-header">
|
<div className="bb-post-header">
|
||||||
<span>{t('thread.by')} {authorName}</span>
|
<div className="bb-post-header-meta">
|
||||||
{post.created_at && (
|
{topicLabel && (
|
||||||
<span>{post.created_at.slice(0, 10)}</span>
|
<span className="bb-post-topic">
|
||||||
)}
|
#{postNumber} {topicLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{t('thread.by')} {authorName}</span>
|
||||||
|
{post.created_at && (
|
||||||
|
<span>{post.created_at.slice(0, 10)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bb-post-actions">
|
||||||
|
<button type="button" className="bb-post-action" aria-label="Edit post">
|
||||||
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="bb-post-action" aria-label="Delete post">
|
||||||
|
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="bb-post-action" aria-label="Report post">
|
||||||
|
<i className="bi bi-exclamation-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="bb-post-action" aria-label="Post info">
|
||||||
|
<i className="bi bi-info-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||||
|
<i className="bi bi-quote" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-body">{post.body}</div>
|
<div className="bb-post-body">{post.body}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Container, Form, Row, Col } from 'react-bootstrap'
|
import { Container, Form, Row, Col } from 'react-bootstrap'
|
||||||
|
import { getCurrentUser, uploadAvatar } from '../api/client'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
const { token } = useAuth()
|
||||||
const accentMode = accentOverride ? 'custom' : 'system'
|
const accentMode = accentOverride ? 'custom' : 'system'
|
||||||
|
const [avatarError, setAvatarError] = useState('')
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
getCurrentUser()
|
||||||
|
.then((data) => {
|
||||||
|
if (!active) return
|
||||||
|
setAvatarPreview(data?.avatar_url || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setAvatarPreview('')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
const handleLanguageChange = (event) => {
|
const handleLanguageChange = (event) => {
|
||||||
const locale = event.target.value
|
const locale = event.target.value
|
||||||
@@ -12,7 +37,48 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5 bb-portal-shell">
|
<Container fluid className="py-5 bb-portal-shell">
|
||||||
|
<div className="bb-portal-card mb-4">
|
||||||
|
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
|
||||||
|
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
|
||||||
|
<Row className="g-3 align-items-center">
|
||||||
|
<Col md="auto">
|
||||||
|
<div className="bb-avatar-preview">
|
||||||
|
{avatarPreview ? (
|
||||||
|
<img src={avatarPreview} alt="" />
|
||||||
|
) : (
|
||||||
|
<i className="bi bi-person" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||||
|
disabled={!token || avatarUploading}
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setAvatarError('')
|
||||||
|
setAvatarUploading(true)
|
||||||
|
try {
|
||||||
|
const response = await uploadAvatar(file)
|
||||||
|
setAvatarPreview(response.url)
|
||||||
|
} catch (err) {
|
||||||
|
setAvatarError(err.message)
|
||||||
|
} finally {
|
||||||
|
setAvatarUploading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
<div className="bb-portal-card">
|
<div className="bb-portal-card">
|
||||||
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
|
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
|
||||||
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
|
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
"acp.users": "Benutzer",
|
"acp.users": "Benutzer",
|
||||||
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
||||||
"auth.login_title": "Anmelden",
|
"auth.login_title": "Anmelden",
|
||||||
|
"auth.login_identifier": "E-Mail oder Benutzername",
|
||||||
|
"auth.login_placeholder": "name@example.com oder benutzername",
|
||||||
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
||||||
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||||
"auth.register_title": "Konto erstellen",
|
"auth.register_title": "Konto erstellen",
|
||||||
@@ -99,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",
|
||||||
@@ -148,8 +167,18 @@
|
|||||||
"portal.user_control_panel": "Benutzerkontrollzentrum",
|
"portal.user_control_panel": "Benutzerkontrollzentrum",
|
||||||
"portal.user_profile": "Profil",
|
"portal.user_profile": "Profil",
|
||||||
"portal.user_logout": "Logout",
|
"portal.user_logout": "Logout",
|
||||||
|
"portal.user_role_operator": "Operator",
|
||||||
|
"portal.user_role_moderator": "Moderator",
|
||||||
|
"portal.user_role_member": "Mitglied",
|
||||||
"portal.advertisement": "Werbung",
|
"portal.advertisement": "Werbung",
|
||||||
|
"profile.title": "Profil",
|
||||||
|
"profile.loading": "Profil wird geladen...",
|
||||||
|
"profile.registered": "Registriert:",
|
||||||
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
||||||
|
"ucp.profile": "Profil",
|
||||||
|
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
|
||||||
|
"ucp.avatar_label": "Profilbild",
|
||||||
|
"ucp.avatar_hint": "Lade ein Bild hoch (max. 150x150px, Du kannst jpg, png, gif oder webp verwenden).",
|
||||||
"ucp.system_default": "Systemstandard",
|
"ucp.system_default": "Systemstandard",
|
||||||
"ucp.accent_override": "Akzentfarbe überschreiben",
|
"ucp.accent_override": "Akzentfarbe überschreiben",
|
||||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||||
@@ -161,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",
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
"acp.users": "Users",
|
"acp.users": "Users",
|
||||||
"auth.login_hint": "Access your account to start new threads and reply.",
|
"auth.login_hint": "Access your account to start new threads and reply.",
|
||||||
"auth.login_title": "Log in",
|
"auth.login_title": "Log in",
|
||||||
|
"auth.login_identifier": "Email or username",
|
||||||
|
"auth.login_placeholder": "name@example.com or username",
|
||||||
"auth.register_hint": "Register with an email and a unique username.",
|
"auth.register_hint": "Register with an email and a unique username.",
|
||||||
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
||||||
"auth.register_title": "Create account",
|
"auth.register_title": "Create account",
|
||||||
@@ -99,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",
|
||||||
@@ -148,8 +167,18 @@
|
|||||||
"portal.user_control_panel": "User Control Panel",
|
"portal.user_control_panel": "User Control Panel",
|
||||||
"portal.user_profile": "Profile",
|
"portal.user_profile": "Profile",
|
||||||
"portal.user_logout": "Logout",
|
"portal.user_logout": "Logout",
|
||||||
|
"portal.user_role_operator": "Operator",
|
||||||
|
"portal.user_role_moderator": "Moderator",
|
||||||
|
"portal.user_role_member": "Member",
|
||||||
"portal.advertisement": "Advertisement",
|
"portal.advertisement": "Advertisement",
|
||||||
|
"profile.title": "Profile",
|
||||||
|
"profile.loading": "Loading profile...",
|
||||||
|
"profile.registered": "Registered:",
|
||||||
"ucp.intro": "Manage your basic preferences for the forum.",
|
"ucp.intro": "Manage your basic preferences for the forum.",
|
||||||
|
"ucp.profile": "Profile",
|
||||||
|
"ucp.profile_hint": "Update the avatar shown next to your posts.",
|
||||||
|
"ucp.avatar_label": "Profile image",
|
||||||
|
"ucp.avatar_hint": "Upload an image (max 150x150px, you can use jpg, png, gif, or webp).",
|
||||||
"ucp.system_default": "System default",
|
"ucp.system_default": "System default",
|
||||||
"ucp.accent_override": "Accent color override",
|
"ucp.accent_override": "Accent color override",
|
||||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||||
@@ -161,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,11 +10,18 @@ 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']);
|
||||||
Route::post('/register', [AuthController::class, 'register']);
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
|
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('guest');
|
||||||
|
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('guest');
|
||||||
|
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
|
||||||
|
->middleware('signed')
|
||||||
|
->name('verification.verify');
|
||||||
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/version', VersionController::class);
|
Route::get('/version', VersionController::class);
|
||||||
Route::get('/settings', [SettingController::class, 'index']);
|
Route::get('/settings', [SettingController::class, 'index']);
|
||||||
@@ -24,8 +31,18 @@ Route::get('/user-settings', [UserSettingController::class, 'index'])->middlewar
|
|||||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
||||||
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
|
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->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/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']);
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::view('/', 'app');
|
Route::view('/', 'app');
|
||||||
|
Route::view('/login', 'app')->name('login');
|
||||||
|
Route::view('/reset-password', 'app')->name('password.reset');
|
||||||
|
|
||||||
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
|
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
|
||||||
|
|||||||
Reference in New Issue
Block a user