Compare commits
7 Commits
acp-cleanu
...
fix-replie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c16ed0dd | ||
|
|
f9de433545 | ||
|
|
fd29b928d8 | ||
|
|
98094459e3 | ||
|
|
3bb2946656 | ||
|
|
bbbf8eb6c1 | ||
|
|
c8d2bd508e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
._*
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -22,6 +23,7 @@
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*.php
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
||||
# 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.
|
||||
- Added profile location field with UCP editing and post sidebar display.
|
||||
- Added per-thread replies and views counts, including view tracking.
|
||||
- Added per-forum topics/views counts plus last-post details in board listings.
|
||||
- Added portal summary API to load forums, stats, and recent posts in one request.
|
||||
- Unified portal and forum thread list row styling with shared component.
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -19,8 +20,16 @@ class CreateNewUser implements CreatesNewUsers
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||
|
||||
Validator::make(data: $input, rules: [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'name_canonical' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique(table: User::class, column: 'name_canonical'),
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -33,6 +42,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
|
||||
return User::create(attributes: [
|
||||
'name' => $input['name'],
|
||||
'name_canonical' => $input['name_canonical'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make(value: $input['password']),
|
||||
]);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'name_canonical' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('users', 'name_canonical')->ignore($user->id),
|
||||
],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
@@ -34,6 +43,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'name_canonical' => $input['name_canonical'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
@@ -48,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'name_canonical' => $input['name_canonical'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||
{
|
||||
$input = [
|
||||
@@ -33,16 +41,30 @@ class AuthController extends Controller
|
||||
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->merge([
|
||||
'login' => $request->input('login', $request->input('email')),
|
||||
]);
|
||||
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'login' => ['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)) {
|
||||
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
|
||||
{
|
||||
$request->user()?->currentAccessToken()?->delete();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -11,7 +12,10 @@ class ForumController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Forum::query()->withoutTrashed();
|
||||
$query = Forum::query()
|
||||
->withoutTrashed()
|
||||
->withCount(['threads', 'posts'])
|
||||
->withSum('threads', 'views_count');
|
||||
|
||||
$parentParam = $request->query('parent');
|
||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
@@ -35,15 +39,24 @@ class ForumController extends Controller
|
||||
$forums = $query
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Forum $forum) => $this->serializeForum($forum));
|
||||
->get();
|
||||
|
||||
return response()->json($forums);
|
||||
$forumIds = $forums->pluck('id')->all();
|
||||
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
|
||||
|
||||
$payload = $forums->map(
|
||||
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
|
||||
);
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
public function show(Forum $forum): JsonResponse
|
||||
{
|
||||
return response()->json($this->serializeForum($forum));
|
||||
$forum->loadCount(['threads', 'posts'])
|
||||
->loadSum('threads', 'views_count');
|
||||
$lastPost = $this->loadLastPostForForum($forum->id);
|
||||
return response()->json($this->serializeForum($forum, $lastPost));
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -68,7 +81,12 @@ class ForumController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($parentId === null) {
|
||||
Forum::whereNull('parent_id')->increment('position');
|
||||
$position = 0;
|
||||
} else {
|
||||
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||
}
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => $data['name'],
|
||||
@@ -78,7 +96,11 @@ class ForumController extends Controller
|
||||
'position' => ($position ?? 0) + 1,
|
||||
]);
|
||||
|
||||
return response()->json($this->serializeForum($forum), 201);
|
||||
$forum->loadCount(['threads', 'posts'])
|
||||
->loadSum('threads', 'views_count');
|
||||
$lastPost = $this->loadLastPostForForum($forum->id);
|
||||
|
||||
return response()->json($this->serializeForum($forum, $lastPost), 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, Forum $forum): JsonResponse
|
||||
@@ -122,7 +144,11 @@ class ForumController extends Controller
|
||||
|
||||
$forum->save();
|
||||
|
||||
return response()->json($this->serializeForum($forum));
|
||||
$forum->loadCount(['threads', 'posts'])
|
||||
->loadSum('threads', 'views_count');
|
||||
$lastPost = $this->loadLastPostForForum($forum->id);
|
||||
|
||||
return response()->json($this->serializeForum($forum, $lastPost));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Forum $forum): JsonResponse
|
||||
@@ -175,7 +201,7 @@ class ForumController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
private function serializeForum(Forum $forum): array
|
||||
private function serializeForum(Forum $forum, ?Post $lastPost): array
|
||||
{
|
||||
return [
|
||||
'id' => $forum->id,
|
||||
@@ -184,8 +210,54 @@ class ForumController extends Controller
|
||||
'type' => $forum->type,
|
||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||
'position' => $forum->position,
|
||||
'threads_count' => $forum->threads_count ?? 0,
|
||||
'posts_count' => $forum->posts_count ?? 0,
|
||||
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadLastPostsByForum(array $forumIds): array
|
||||
{
|
||||
if (empty($forumIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$posts = Post::query()
|
||||
->select('posts.*', 'threads.forum_id as forum_id')
|
||||
->join('threads', 'posts.thread_id', '=', 'threads.id')
|
||||
->whereIn('threads.forum_id', $forumIds)
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
foreach ($posts as $post) {
|
||||
$forumId = (int) ($post->forum_id ?? 0);
|
||||
if ($forumId && !array_key_exists($forumId, $byForum)) {
|
||||
$byForum[$forumId] = $post;
|
||||
}
|
||||
}
|
||||
|
||||
return $byForum;
|
||||
}
|
||||
|
||||
private function loadLastPostForForum(int $forumId): ?Post
|
||||
{
|
||||
return Post::query()
|
||||
->select('posts.*')
|
||||
->join('threads', 'posts.thread_id', '=', 'threads.id')
|
||||
->where('threads.forum_id', $forumId)
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
149
app/Http/Controllers/PortalController.php
Normal file
149
app/Http/Controllers/PortalController.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PortalController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$forums = Forum::query()
|
||||
->withoutTrashed()
|
||||
->withCount(['threads', 'posts'])
|
||||
->withSum('threads', 'views_count')
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$forumIds = $forums->pluck('id')->all();
|
||||
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
|
||||
|
||||
$forumPayload = $forums->map(
|
||||
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
|
||||
);
|
||||
|
||||
$threads = Thread::query()
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
])
|
||||
->latest('created_at')
|
||||
->limit(12)
|
||||
->get()
|
||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||
|
||||
$stats = [
|
||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count(),
|
||||
'users' => User::query()->count(),
|
||||
];
|
||||
|
||||
$user = auth('sanctum')->user();
|
||||
|
||||
return response()->json([
|
||||
'forums' => $forumPayload,
|
||||
'threads' => $threads,
|
||||
'stats' => $stats,
|
||||
'profile' => $user ? [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar_url' => $user->avatar_path ? Storage::url($user->avatar_path) : null,
|
||||
'location' => $user->location,
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function serializeForum(Forum $forum, ?Post $lastPost): array
|
||||
{
|
||||
return [
|
||||
'id' => $forum->id,
|
||||
'name' => $forum->name,
|
||||
'description' => $forum->description,
|
||||
'type' => $forum->type,
|
||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||
'position' => $forum->position,
|
||||
'threads_count' => $forum->threads_count ?? 0,
|
||||
'posts_count' => $forum->posts_count ?? 0,
|
||||
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeThread(Thread $thread): array
|
||||
{
|
||||
return [
|
||||
'id' => $thread->id,
|
||||
'title' => $thread->title,
|
||||
'body' => $thread->body,
|
||||
'forum' => "/api/forums/{$thread->forum_id}",
|
||||
'user_id' => $thread->user_id,
|
||||
'posts_count' => $thread->posts_count ?? 0,
|
||||
'views_count' => $thread->views_count ?? 0,
|
||||
'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,
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadLastPostsByForum(array $forumIds): array
|
||||
{
|
||||
if (empty($forumIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$posts = Post::query()
|
||||
->select('posts.*', 'threads.forum_id as forum_id')
|
||||
->join('threads', 'posts.thread_id', '=', 'threads.id')
|
||||
->whereIn('threads.forum_id', $forumIds)
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
foreach ($posts as $post) {
|
||||
$forumId = (int) ($post->forum_id ?? 0);
|
||||
if ($forumId && !array_key_exists($forumId, $byForum)) {
|
||||
$byForum[$forumId] = $post;
|
||||
}
|
||||
}
|
||||
|
||||
return $byForum;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
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');
|
||||
if (is_string($threadParam)) {
|
||||
@@ -45,6 +48,10 @@ class PostController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
}
|
||||
|
||||
@@ -81,6 +88,19 @@ class PostController extends Controller
|
||||
'body' => $post->body,
|
||||
'thread' => "/api/threads/{$post->thread_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_location' => $post->user?->location,
|
||||
'user_rank_name' => $post->user?->rank?->name,
|
||||
'user_rank_badge_type' => $post->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $post->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
|
||||
? Storage::url($post->user->rank->badge_image_path)
|
||||
: null,
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
153
app/Http/Controllers/RankController.php
Normal file
153
app/Http/Controllers/RankController.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Rank;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class RankController extends Controller
|
||||
{
|
||||
private function ensureAdmin(Request $request): ?JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$ranks = Rank::query()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Rank $rank) => [
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
]);
|
||||
|
||||
return response()->json($ranks);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $data['name'])
|
||||
: null;
|
||||
|
||||
$rank = Rank::create([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => null,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, Rank $rank): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||
: null;
|
||||
|
||||
if ($badgeType === 'text' && $rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
$rank->badge_image_path = null;
|
||||
}
|
||||
|
||||
$rank->update([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Rank $rank): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if ($rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
}
|
||||
|
||||
$rank->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function uploadBadgeImage(Request $request, Rank $rank): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'],
|
||||
]);
|
||||
|
||||
if ($rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
}
|
||||
|
||||
$path = $data['file']->store('rank-badges', 'public');
|
||||
$rank->badge_type = 'image';
|
||||
$rank->badge_text = null;
|
||||
$rank->badge_image_path = $path;
|
||||
$rank->save();
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'badge_image_url' => Storage::url($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
20
app/Http/Controllers/StatsController.php
Normal file
20
app/Http/Controllers/StatsController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class StatsController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count(),
|
||||
'users' => User::query()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,19 @@ use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Thread::query()->withoutTrashed()->with('user');
|
||||
$query = Thread::query()
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
]);
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
if (is_string($forumParam)) {
|
||||
@@ -31,7 +38,12 @@ class ThreadController extends Controller
|
||||
|
||||
public function show(Thread $thread): JsonResponse
|
||||
{
|
||||
$thread->loadMissing('user');
|
||||
$thread->increment('views_count');
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
])->loadCount('posts');
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
@@ -57,6 +69,11 @@ class ThreadController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread), 201);
|
||||
}
|
||||
|
||||
@@ -94,7 +111,27 @@ class ThreadController extends Controller
|
||||
'body' => $thread->body,
|
||||
'forum' => "/api/forums/{$thread->forum_id}",
|
||||
'user_id' => $thread->user_id,
|
||||
'posts_count' => $thread->posts_count ?? 0,
|
||||
'views_count' => $thread->views_count ?? 0,
|
||||
'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_location' => $thread->user?->location,
|
||||
'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,
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
@@ -8,6 +8,37 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
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
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -4,22 +4,194 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$users = User::query()
|
||||
->with('roles')
|
||||
->with(['roles', 'rank'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (User $user) => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||
'location' => $user->location,
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
]);
|
||||
|
||||
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),
|
||||
'location' => $user->location,
|
||||
'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),
|
||||
'location' => $user->location,
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'created_at' => $user->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateMe(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$location = isset($data['location']) ? trim($data['location']) : null;
|
||||
if ($location === '') {
|
||||
$location = null;
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'location' => $location,
|
||||
])->save();
|
||||
|
||||
$user->loadMissing('rank');
|
||||
|
||||
return response()->json([
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar_url' => $this->resolveAvatarUrl($user),
|
||||
'location' => $user->location,
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
] : null,
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -60,4 +63,20 @@ class Forum extends Model
|
||||
{
|
||||
return $this->hasMany(Thread::class);
|
||||
}
|
||||
|
||||
public function posts(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(Post::class, Thread::class, 'forum_id', 'thread_id');
|
||||
}
|
||||
|
||||
public function latestThread(): HasOne
|
||||
{
|
||||
return $this->hasOne(Thread::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function latestPost(): HasOneThrough
|
||||
{
|
||||
return $this->hasOneThrough(Post::class, Thread::class, 'forum_id', 'thread_id')
|
||||
->latestOfMany();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -56,4 +57,9 @@ class Thread extends Model
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function latestPost(): HasOne
|
||||
{
|
||||
return $this->hasOne(Post::class)->latestOfMany();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
@@ -64,6 +65,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'name_canonical',
|
||||
'avatar_path',
|
||||
'location',
|
||||
'rank_id',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
@@ -95,4 +100,14 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function rank()
|
||||
{
|
||||
return $this->belongsTo(Rank::class);
|
||||
}
|
||||
}
|
||||
|
||||
4
artisan
4
artisan
@@ -4,7 +4,7 @@
|
||||
use Illuminate\Foundation\Application;
|
||||
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...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
@@ -13,6 +13,6 @@ require __DIR__.'/vendor/autoload.php';
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
$status = $app->handleCommand(input: new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"laravel/fortify": "*",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "*",
|
||||
|
||||
@@ -48,7 +48,7 @@ return [
|
||||
'timeout' => null,
|
||||
'local_domain' => env(
|
||||
'MAIL_EHLO_DOMAIN',
|
||||
parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)
|
||||
parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST)
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ class UserFactory extends Factory
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->userName();
|
||||
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'name' => $name,
|
||||
'name_canonical' => Str::lower($name),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('location')->nullable()->after('avatar_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('location');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('threads', function (Blueprint $table) {
|
||||
$table->unsignedInteger('views_count')->default(0)->after('body');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('threads', function (Blueprint $table) {
|
||||
$table->dropColumn('views_count');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([
|
||||
RoleSeeder::class,
|
||||
RankSeeder::class,
|
||||
UserSeeder::class,
|
||||
ForumSeeder::class,
|
||||
ThreadSeeder::class,
|
||||
|
||||
41
database/seeders/RankSeeder.php
Normal file
41
database/seeders/RankSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Rank;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class RankSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$member = Rank::firstOrCreate(
|
||||
['name' => 'Member'],
|
||||
['badge_type' => 'text', 'badge_text' => 'Member']
|
||||
);
|
||||
$operator = Rank::firstOrCreate(
|
||||
['name' => 'Operator'],
|
||||
['badge_type' => 'text', 'badge_text' => 'Operator']
|
||||
);
|
||||
$moderator = Rank::firstOrCreate(
|
||||
['name' => 'Moderator'],
|
||||
['badge_type' => 'text', 'badge_text' => 'Moderator']
|
||||
);
|
||||
|
||||
User::query()
|
||||
->whereNull('rank_id')
|
||||
->update(['rank_id' => $member->id]);
|
||||
|
||||
User::query()
|
||||
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_ADMIN'))
|
||||
->update(['rank_id' => $operator->id]);
|
||||
|
||||
User::query()
|
||||
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_MODERATOR'))
|
||||
->update(['rank_id' => $moderator->id]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
|
||||
@@ -14,14 +16,29 @@ class UserSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$adminRole = Role::where('name', 'ROLE_ADMIN')->first();
|
||||
$userRole = Role::where('name', 'ROLE_USER')->first();
|
||||
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
|
||||
$userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first();
|
||||
$operatorRank = Rank::where('name', 'Operator')->first();
|
||||
$memberRank = Rank::where('name', 'Member')->first();
|
||||
|
||||
$admin = User::firstOrCreate(
|
||||
['email' => 'tracer@24unix.net'],
|
||||
[
|
||||
$admin = User::updateOrCreate(
|
||||
attributes: ['email' => 'tracer@24unix.net'],
|
||||
values : [
|
||||
'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(),
|
||||
]
|
||||
);
|
||||
@@ -34,6 +51,10 @@ class UserSeeder extends Seeder
|
||||
$admin->roles()->syncWithoutDetaching([$userRole->id]);
|
||||
}
|
||||
|
||||
if ($userRole) {
|
||||
$micha->roles()->syncWithoutDetaching([$userRole->id]);
|
||||
}
|
||||
|
||||
$users = User::factory()->count(100)->create([
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -10,10 +10,19 @@ import Register from './pages/Register'
|
||||
import Acp from './pages/Acp'
|
||||
import BoardIndex from './pages/BoardIndex'
|
||||
import Ucp from './pages/Ucp'
|
||||
import Profile from './pages/Profile'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 location = useLocation()
|
||||
const [crumbs, setCrumbs] = useState([])
|
||||
@@ -96,6 +105,33 @@ 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
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/ucp')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.user_control_panel'), to: '/ucp', current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/profile/')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.user_profile'), to: location.pathname, current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
}
|
||||
|
||||
@@ -107,15 +143,17 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
||||
}, [location.pathname, t])
|
||||
|
||||
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-brand">
|
||||
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||
{logoUrl && (
|
||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||
)}
|
||||
{(showHeaderName || !logoUrl) && (
|
||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bb-portal-search">
|
||||
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
||||
@@ -135,12 +173,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
||||
<span>
|
||||
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
|
||||
</span>
|
||||
{isAuthenticated && canAccessAcp && (
|
||||
<>
|
||||
<Link to="/acp" className="bb-portal-link">
|
||||
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && canAccessMcp && (
|
||||
<span>
|
||||
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -197,7 +241,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation()
|
||||
const { token, email, logout, isAdmin } = useAuth()
|
||||
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
|
||||
const [versionInfo, setVersionInfo] = useState(null)
|
||||
const [theme, setTheme] = useState('auto')
|
||||
const [resolvedTheme, setResolvedTheme] = useState('light')
|
||||
@@ -403,7 +447,7 @@ function AppShell() {
|
||||
<NavDropdown.Item as={Link} to="/ucp">
|
||||
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
|
||||
</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')}
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
@@ -413,6 +457,8 @@ function AppShell() {
|
||||
</NavDropdown>
|
||||
) : null
|
||||
}
|
||||
canAccessAcp={isAdmin}
|
||||
canAccessMcp={isModerator}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
@@ -421,6 +467,7 @@ function AppShell() {
|
||||
<Route path="/thread/:id" element={<ThreadView />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/profile/:id" element={<Profile />} />
|
||||
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
||||
<Route
|
||||
path="/ucp"
|
||||
|
||||
@@ -48,10 +48,10 @@ export async function getCollection(path) {
|
||||
return data?.['hydra:member'] || []
|
||||
}
|
||||
|
||||
export async function login(email, password) {
|
||||
export async function login(login, password) {
|
||||
return apiFetch('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ login, password }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,10 +70,45 @@ export async function listAllForums() {
|
||||
return getCollection('/forums?pagination=false')
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
return apiFetch('/user/me')
|
||||
}
|
||||
|
||||
export async function updateCurrentUser(payload) {
|
||||
return apiFetch('/user/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
return apiFetch('/version')
|
||||
}
|
||||
|
||||
export async function fetchStats() {
|
||||
return apiFetch('/stats')
|
||||
}
|
||||
|
||||
export async function fetchPortalSummary() {
|
||||
return apiFetch('/portal/summary')
|
||||
}
|
||||
|
||||
export async function fetchSetting(key) {
|
||||
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
||||
const cacheBust = Date.now()
|
||||
@@ -211,6 +246,53 @@ export async function listUsers() {
|
||||
return getCollection('/users')
|
||||
}
|
||||
|
||||
export async function listRanks() {
|
||||
return getCollection('/ranks')
|
||||
}
|
||||
|
||||
export async function updateUserRank(userId, rankId) {
|
||||
return apiFetch(`/users/${userId}/rank`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ rank_id: rankId }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createRank(payload) {
|
||||
return apiFetch('/ranks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateRank(rankId, payload) {
|
||||
return apiFetch(`/ranks/${rankId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteRank(rankId) {
|
||||
return apiFetch(`/ranks/${rankId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadRankBadgeImage(rankId, file) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return apiFetch(`/ranks/${rankId}/badge-image`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateUser(userId, payload) {
|
||||
return apiFetch(`/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createThread({ title, body, forumId }) {
|
||||
return apiFetch('/threads', {
|
||||
method: 'POST',
|
||||
|
||||
91
resources/js/components/PortalTopicRow.jsx
Normal file
91
resources/js/components/PortalTopicRow.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) {
|
||||
const { t } = useTranslation()
|
||||
const authorName = thread.user_name || t('thread.anonymous')
|
||||
const lastAuthorName = thread.last_post_user_name || authorName
|
||||
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bb-portal-topic-row">
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<div className="bb-portal-topic-meta-line">
|
||||
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
||||
{thread.user_id ? (
|
||||
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-portal-topic-author">{authorName}</span>
|
||||
)}
|
||||
<span className="bb-portal-topic-meta-sep">»</span>
|
||||
<span className="bb-portal-topic-meta-date">{formatDateTime(thread.created_at)}</span>
|
||||
</div>
|
||||
{showForum && (
|
||||
<div className="bb-portal-topic-meta-line">
|
||||
<span className="bb-portal-topic-meta-label">{t('portal.forum_label')}</span>
|
||||
<span className="bb-portal-topic-forum">
|
||||
{forumId ? (
|
||||
<Link to={`/forum/${forumId}`} className="bb-portal-topic-forum-link">
|
||||
{forumName}
|
||||
</Link>
|
||||
) : (
|
||||
forumName
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
|
||||
<div className="bb-portal-last">
|
||||
<span className="bb-portal-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{thread.last_post_user_id ? (
|
||||
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
|
||||
{lastAuthorName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="bb-portal-last-user">{lastAuthorName}</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/thread/${thread.id}${lastPostAnchor}`}
|
||||
className="bb-portal-last-jump ms-2"
|
||||
aria-label={t('thread.view')}
|
||||
>
|
||||
<i className="bi bi-eye" aria-hidden="true" />
|
||||
</Link>
|
||||
</span>
|
||||
<span className="bb-portal-last-date">
|
||||
{formatDateTime(thread.last_post_at || thread.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
|
||||
userId: effectiveUserId,
|
||||
roles: effectiveRoles,
|
||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||
async login(emailInput, password) {
|
||||
const data = await apiLogin(emailInput, password)
|
||||
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
|
||||
async login(loginInput, password) {
|
||||
const data = await apiLogin(loginInput, password)
|
||||
localStorage.setItem('speedbb_token', data.token)
|
||||
localStorage.setItem('speedbb_email', data.email || emailInput)
|
||||
localStorage.setItem('speedbb_email', data.email || loginInput)
|
||||
if (data.user_id) {
|
||||
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
||||
setUserId(String(data.user_id))
|
||||
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
|
||||
setRoles([])
|
||||
}
|
||||
setToken(data.token)
|
||||
setEmail(data.email || emailInput)
|
||||
setEmail(data.email || loginInput)
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem('speedbb_token')
|
||||
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
|
||||
userId: effectiveUserId,
|
||||
roles: effectiveRoles,
|
||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
|
||||
hasToken: Boolean(token),
|
||||
})
|
||||
}, [email, effectiveUserId, effectiveRoles, token])
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
--bb-gold: #e4a634;
|
||||
--bb-peach: #f4c7a3;
|
||||
--bb-border: #e0d7c7;
|
||||
--bb-shell-max: 1880px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -88,6 +89,328 @@ a {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.bb-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.bb-thread-titlebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bb-thread-title {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-thread-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bb-thread-author {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-thread-date {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bb-thread-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: #141822;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bb-thread-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bb-thread-toolbar .bb-accent-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-thread-icon-button {
|
||||
border: 1px solid #2a2f3a;
|
||||
background: #20252f;
|
||||
color: #d0d6df;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.bb-thread-icon-button:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-thread-meta-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bb-thread-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-thread-empty {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
margin-left: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-posts {
|
||||
border: 1px solid var(--bb-border);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #171b22;
|
||||
}
|
||||
|
||||
.bb-post-row {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
border-top: 1px solid var(--bb-border);
|
||||
}
|
||||
|
||||
.bb-post-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.bb-post-author {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-right: 1px solid var(--bb-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bb-post-avatar {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
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 {
|
||||
font-weight: 600;
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
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 {
|
||||
padding: 1rem 1.35rem 1.2rem;
|
||||
}
|
||||
|
||||
.bb-post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bb-ink-muted);
|
||||
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 {
|
||||
white-space: pre-wrap;
|
||||
color: var(--bb-ink);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bb-thread-reply {
|
||||
border: 1px solid var(--bb-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.1rem 1.2rem 1.4rem;
|
||||
background: #171b22;
|
||||
}
|
||||
|
||||
.bb-thread-reply-title {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bb-thread-reply-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.bb-post-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bb-post-author {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--bb-border);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bb-post-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.bb-forum-row {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
@@ -513,8 +836,9 @@ a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-shell {
|
||||
max-width: 1400px;
|
||||
.container.bb-portal-shell,
|
||||
.container.bb-shell-container {
|
||||
max-width: var(--bb-shell-max);
|
||||
}
|
||||
|
||||
.bb-portal-banner {
|
||||
@@ -544,6 +868,13 @@ a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bb-portal-logo-link {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bb-portal-logo-image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
@@ -850,6 +1181,33 @@ a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-board-last {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bb-board-last-by {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-board-last-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-board-last-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-board-last-date {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bb-board-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -953,6 +1311,56 @@ a {
|
||||
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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -1031,19 +1439,50 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta {
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-label {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-sep {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta-date {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bb-portal-topic-forum {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-author {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-author:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-topic-forum-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
@@ -1060,6 +1499,49 @@ a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-cell--last {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bb-portal-last {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bb-portal-last-by {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-last-user {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-last-user:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-last-jump {
|
||||
color: var(--bb-ink-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-portal-last-jump:hover {
|
||||
color: var(--bb-ink-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-last-date {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bb-portal-user-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1069,11 +1551,33 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-user-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 12px;
|
||||
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);
|
||||
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-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-portal-user-name-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-user-name {
|
||||
@@ -1082,7 +1586,7 @@ a {
|
||||
|
||||
.bb-portal-user-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
@@ -1291,6 +1795,109 @@ a {
|
||||
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 {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
deleteForum,
|
||||
fetchSettings,
|
||||
listAllForums,
|
||||
listRanks,
|
||||
listUsers,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
saveSettings,
|
||||
createRank,
|
||||
deleteRank,
|
||||
updateUserRank,
|
||||
updateRank,
|
||||
updateUser,
|
||||
uploadRankBadgeImage,
|
||||
uploadFavicon,
|
||||
uploadLogo,
|
||||
updateForum,
|
||||
@@ -28,10 +35,33 @@ export default function Acp({ isAdmin }) {
|
||||
const pendingOrder = useRef(null)
|
||||
const [createType, setCreateType] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [usersError, setUsersError] = useState('')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||
const [ranks, setRanks] = useState([])
|
||||
const [ranksLoading, setRanksLoading] = useState(false)
|
||||
const [ranksError, setRanksError] = useState('')
|
||||
const [rankUpdatingId, setRankUpdatingId] = useState(null)
|
||||
const [rankFormName, setRankFormName] = useState('')
|
||||
const [rankFormType, setRankFormType] = useState('text')
|
||||
const [rankFormText, setRankFormText] = useState('')
|
||||
const [rankFormImage, setRankFormImage] = useState(null)
|
||||
const [rankSaving, setRankSaving] = useState(false)
|
||||
const [showRankModal, setShowRankModal] = useState(false)
|
||||
const [rankEdit, setRankEdit] = useState({
|
||||
id: null,
|
||||
name: '',
|
||||
badgeType: 'text',
|
||||
badgeText: '',
|
||||
badgeImageUrl: '',
|
||||
})
|
||||
const [rankEditImage, setRankEditImage] = useState(null)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
|
||||
const [userSaving, setUserSaving] = useState(false)
|
||||
const [generalSaving, setGeneralSaving] = useState(false)
|
||||
const [generalUploading, setGeneralUploading] = useState(false)
|
||||
const [generalError, setGeneralError] = useState('')
|
||||
@@ -341,17 +371,93 @@ export default function Acp({ isAdmin }) {
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const filteredUsers = useMemo(() => {
|
||||
const term = userSearch.trim().toLowerCase()
|
||||
if (!term) return users
|
||||
return users.filter((user) =>
|
||||
[user.name, user.email, user.rank?.name]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(term))
|
||||
)
|
||||
}, [users, userSearch])
|
||||
|
||||
const userColumns = useMemo(
|
||||
() => [
|
||||
() => {
|
||||
const iconFor = (id) => {
|
||||
if (userSort.columnId !== id) {
|
||||
return 'bi-arrow-down-up'
|
||||
}
|
||||
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: t('user.name'),
|
||||
id: 'name',
|
||||
name: (
|
||||
<span className="bb-sort-label">
|
||||
{t('user.name')}
|
||||
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
|
||||
</span>
|
||||
),
|
||||
selector: (row) => row.name,
|
||||
sortable: true,
|
||||
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
|
||||
sensitivity: 'base',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: (
|
||||
<span className="bb-sort-label">
|
||||
{t('user.email')}
|
||||
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
|
||||
</span>
|
||||
),
|
||||
selector: (row) => row.email,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: t('user.email'),
|
||||
selector: (row) => row.email,
|
||||
id: 'rank',
|
||||
name: (
|
||||
<span className="bb-sort-label">
|
||||
{t('user.rank')}
|
||||
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
|
||||
</span>
|
||||
),
|
||||
width: '220px',
|
||||
sortable: true,
|
||||
sortFunction: (a, b) =>
|
||||
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
|
||||
cell: (row) => (
|
||||
<Form.Select
|
||||
size="sm"
|
||||
value={row.rank?.id ?? ''}
|
||||
disabled={ranksLoading || rankUpdatingId === row.id}
|
||||
onChange={async (event) => {
|
||||
const nextRankId = event.target.value ? Number(event.target.value) : null
|
||||
setRankUpdatingId(row.id)
|
||||
try {
|
||||
const updated = await updateUserRank(row.id, nextRankId)
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === row.id ? { ...user, rank: updated.rank } : user
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setUsersError(err.message)
|
||||
} finally {
|
||||
setRankUpdatingId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{t('user.rank_unassigned')}</option>
|
||||
{ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
@@ -370,7 +476,16 @@ export default function Acp({ isAdmin }) {
|
||||
<Button
|
||||
variant="dark"
|
||||
title={t('user.edit')}
|
||||
onClick={() => console.log('edit user', row)}
|
||||
onClick={() => {
|
||||
setUserForm({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
rankId: row.rank?.id ?? '',
|
||||
})
|
||||
setShowUserModal(true)
|
||||
setUsersError('')
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -385,8 +500,9 @@ export default function Acp({ isAdmin }) {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
]
|
||||
},
|
||||
[t, ranks, ranksLoading, rankUpdatingId, userSort]
|
||||
)
|
||||
const userTableStyles = useMemo(
|
||||
() => ({
|
||||
@@ -536,6 +652,57 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshRanks = async () => {
|
||||
setRanksLoading(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
const data = await listRanks()
|
||||
setRanks(data)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRanksLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
refreshRanks()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const handleCreateRank = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!rankFormName.trim()) return
|
||||
if (rankFormType === 'image' && !rankFormImage) {
|
||||
setRanksError(t('rank.badge_image_required'))
|
||||
return
|
||||
}
|
||||
setRankSaving(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
const created = await createRank({
|
||||
name: rankFormName.trim(),
|
||||
badge_type: rankFormType,
|
||||
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
||||
})
|
||||
let next = created
|
||||
if (rankFormType === 'image' && rankFormImage) {
|
||||
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
|
||||
next = { ...created, ...updated }
|
||||
}
|
||||
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
setRankFormName('')
|
||||
setRankFormType('text')
|
||||
setRankFormText('')
|
||||
setRankFormImage(null)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRankSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
@@ -894,7 +1061,7 @@ export default function Acp({ isAdmin }) {
|
||||
<div className="fw-semibold d-flex align-items-center gap-2">
|
||||
<span>{node.name}</span>
|
||||
</div>
|
||||
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
|
||||
<div className="bb-muted">{node.description || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
@@ -905,7 +1072,8 @@ export default function Acp({ isAdmin }) {
|
||||
>
|
||||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||||
</span>
|
||||
<ButtonGroup size="sm" className="bb-action-group">
|
||||
<div className="bb-tree-action-group">
|
||||
<ButtonGroup size="sm" className="bb-action-group w-100">
|
||||
{node.type === 'category' && (
|
||||
<>
|
||||
<Button
|
||||
@@ -933,6 +1101,7 @@ export default function Acp({ isAdmin }) {
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{node.children?.length > 0 &&
|
||||
(!node.type || node.type !== 'category' || isExpanded(node.id)) && (
|
||||
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
|
||||
@@ -942,7 +1111,7 @@ export default function Acp({ isAdmin }) {
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<Container fluid className="py-5">
|
||||
<h2 className="mb-3">{t('acp.title')}</h2>
|
||||
<p className="bb-muted">{t('acp.no_access')}</p>
|
||||
</Container>
|
||||
@@ -1296,17 +1465,23 @@ export default function Acp({ isAdmin }) {
|
||||
</Tab>
|
||||
<Tab eventKey="users" title={t('acp.users')}>
|
||||
{usersError && <p className="text-danger">{usersError}</p>}
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!usersLoading && (
|
||||
<DataTable
|
||||
columns={userColumns}
|
||||
data={users}
|
||||
data={filteredUsers}
|
||||
pagination
|
||||
striped
|
||||
highlightOnHover={themeMode !== 'dark'}
|
||||
dense
|
||||
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
||||
customStyles={userTableStyles}
|
||||
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
|
||||
defaultSortFieldId="name"
|
||||
onSort={(column, direction) => {
|
||||
setUserSort({ columnId: column.id, direction })
|
||||
}}
|
||||
paginationComponentOptions={{
|
||||
rowsPerPageText: t('table.rows_per_page'),
|
||||
rangeSeparatorText: t('table.range_separator'),
|
||||
@@ -1318,7 +1493,147 @@ export default function Acp({ isAdmin }) {
|
||||
setUsersPage(1)
|
||||
}}
|
||||
paginationComponent={UsersPagination}
|
||||
subHeader
|
||||
subHeaderComponent={
|
||||
<Form.Control
|
||||
className="bb-user-search"
|
||||
value={userSearch}
|
||||
onChange={(event) => setUserSearch(event.target.value)}
|
||||
placeholder={t('user.search')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Row className="g-3 align-items-end mb-3">
|
||||
<Col md={6}>
|
||||
<Form onSubmit={handleCreateRank}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormName}
|
||||
onChange={(event) => setRankFormName(event.target.value)}
|
||||
placeholder={t('rank.name_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-text"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankFormType === 'text'}
|
||||
onChange={() => setRankFormType('text')}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-image"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankFormType === 'image'}
|
||||
onChange={() => setRankFormType('image')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankFormType === 'text' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormText}
|
||||
onChange={(event) => setRankFormText(event.target.value)}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankFormType === 'image' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form>
|
||||
</Col>
|
||||
<Col md="auto">
|
||||
<Button
|
||||
type="button"
|
||||
className="bb-accent-button"
|
||||
onClick={handleCreateRank}
|
||||
disabled={rankSaving || !rankFormName.trim()}
|
||||
>
|
||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!ranksLoading && ranks.length === 0 && (
|
||||
<p className="bb-muted">{t('rank.empty')}</p>
|
||||
)}
|
||||
{!ranksLoading && ranks.length > 0 && (
|
||||
<div className="bb-rank-list">
|
||||
{ranks.map((rank) => (
|
||||
<div key={rank.id} className="bb-rank-row">
|
||||
<div className="bb-rank-main">
|
||||
<span>{rank.name}</span>
|
||||
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||||
<img src={rank.badge_image_url} alt="" />
|
||||
)}
|
||||
{rank.badge_type !== 'image' && rank.badge_text && (
|
||||
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={() => {
|
||||
setRankEdit({
|
||||
id: rank.id,
|
||||
name: rank.name,
|
||||
badgeType: rank.badge_type || 'text',
|
||||
badgeText: rank.badge_text || '',
|
||||
badgeImageUrl: rank.badge_image_url || '',
|
||||
})
|
||||
setRankEditImage(null)
|
||||
setShowRankModal(true)
|
||||
setRanksError('')
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t('rank.delete_confirm'))) return
|
||||
setRankSaving(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
await deleteRank(rank.id)
|
||||
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRankSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -1408,6 +1723,221 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showUserModal}
|
||||
onHide={() => setShowUserModal(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('user.edit_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{usersError && <p className="text-danger">{usersError}</p>}
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
setUserSaving(true)
|
||||
setUsersError('')
|
||||
try {
|
||||
const payload = {
|
||||
name: userForm.name,
|
||||
email: userForm.email,
|
||||
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
||||
}
|
||||
const updated = await updateUser(userForm.id, payload)
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === updated.id ? { ...user, ...updated } : user
|
||||
)
|
||||
)
|
||||
setShowUserModal(false)
|
||||
} catch (err) {
|
||||
setUsersError(err.message)
|
||||
} finally {
|
||||
setUserSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
value={userForm.name}
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={userForm.email}
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('user.rank')}</Form.Label>
|
||||
<Form.Select
|
||||
value={userForm.rankId ?? ''}
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
||||
}
|
||||
disabled={ranksLoading}
|
||||
>
|
||||
<option value="">{t('user.rank_unassigned')}</option>
|
||||
{ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowUserModal(false)}
|
||||
disabled={userSaving}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
|
||||
{userSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRankModal}
|
||||
onHide={() => setShowRankModal(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('rank.edit_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (!rankEdit.name.trim()) return
|
||||
if (rankEdit.badgeType === 'text' && !rankEdit.badgeText.trim()) {
|
||||
setRanksError(t('rank.badge_text_required'))
|
||||
return
|
||||
}
|
||||
setRankSaving(true)
|
||||
setRanksError('')
|
||||
try {
|
||||
const updated = await updateRank(rankEdit.id, {
|
||||
name: rankEdit.name.trim(),
|
||||
badge_type: rankEdit.badgeType,
|
||||
badge_text:
|
||||
rankEdit.badgeType === 'text'
|
||||
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
||||
: null,
|
||||
})
|
||||
let next = updated
|
||||
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
||||
const upload = await uploadRankBadgeImage(rankEdit.id, rankEditImage)
|
||||
next = { ...updated, ...upload }
|
||||
}
|
||||
setRanks((prev) =>
|
||||
prev
|
||||
.map((item) => (item.id === next.id ? { ...item, ...next } : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
setShowRankModal(false)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
setRankSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankEdit.name}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-edit-badge-text"
|
||||
name="rankEditBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankEdit.badgeType === 'text'}
|
||||
onChange={() =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
|
||||
}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-edit-badge-image"
|
||||
name="rankEditBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankEdit.badgeType === 'image'}
|
||||
onChange={() =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankEdit.badgeType === 'text' && (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankEdit.badgeText}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
|
||||
}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankEdit.badgeType === 'image' && (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
{rankEdit.badgeImageUrl && !rankEditImage && (
|
||||
<div className="bb-rank-badge-preview">
|
||||
<img src={rankEdit.badgeImageUrl} alt="" />
|
||||
</div>
|
||||
)}
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowRankModal(false)}
|
||||
disabled={rankSaving}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
|
||||
{rankSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function BoardIndex() {
|
||||
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
||||
{node.name}
|
||||
</Link>
|
||||
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div>
|
||||
<div className="bb-board-desc">{node.description || ''}</div>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="bb-board-subforums">
|
||||
{t('forum.children')}:{' '}
|
||||
@@ -130,16 +130,34 @@ export default function BoardIndex() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-board-cell bb-board-cell--topics">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
|
||||
<div className="bb-board-cell bb-board-cell--last">
|
||||
{node.last_post_at ? (
|
||||
<div className="bb-board-last">
|
||||
<span className="bb-board-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{node.last_post_user_id ? (
|
||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
||||
{node.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="bb-board-last-date">
|
||||
{node.last_post_at.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
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>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{!loading && forumTree.length === 0 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||
import PortalTopicRow from '../components/PortalTopicRow'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -31,14 +32,30 @@ export default function ForumView() {
|
||||
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
||||
{node.name}
|
||||
</Link>
|
||||
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div>
|
||||
<div className="bb-board-desc">{node.description || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-board-cell bb-board-cell--topics">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
|
||||
<div className="bb-board-cell bb-board-cell--last">
|
||||
{node.last_post_at ? (
|
||||
<div className="bb-board-last">
|
||||
<span className="bb-board-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{node.last_post_user_id ? (
|
||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
||||
{node.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -96,7 +113,7 @@ export default function ForumView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<Container fluid className="py-5 bb-shell-container">
|
||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{forum && (
|
||||
@@ -156,56 +173,24 @@ export default function ForumView() {
|
||||
</div>
|
||||
</div>
|
||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||
<div className="bb-topic-table">
|
||||
<div className="bb-topic-header">
|
||||
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
|
||||
<div className="bb-portal-topic-table">
|
||||
<div className="bb-portal-topic-header">
|
||||
<span>{t('portal.topic')}</span>
|
||||
<span>{t('thread.replies')}</span>
|
||||
<span>{t('thread.views')}</span>
|
||||
<span>{t('thread.last_post')}</span>
|
||||
</div>
|
||||
{threads.length === 0 && (
|
||||
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<div className="bb-topic-row" key={thread.id}>
|
||||
<div className="bb-topic-cell bb-topic-cell--title">
|
||||
<div className="bb-topic-title">
|
||||
<span className="bb-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left" />
|
||||
</span>
|
||||
<div className="bb-topic-text">
|
||||
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
|
||||
<div className="bb-topic-meta">
|
||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||
<span>{t('thread.by')}</span>
|
||||
<span className="bb-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-topic-date">
|
||||
{thread.created_at.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">
|
||||
<div className="bb-topic-last">
|
||||
<span className="bb-topic-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
<span className="bb-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalTopicRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
forumName={forum?.name || t('portal.unknown_forum')}
|
||||
forumId={forum?.id}
|
||||
showForum={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,30 +1,61 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Badge, Container } from 'react-bootstrap'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listAllForums, listThreads } from '../api/client'
|
||||
import { fetchPortalSummary } from '../api/client'
|
||||
import PortalTopicRow from '../components/PortalTopicRow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Home() {
|
||||
const [forums, setForums] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const [loadingForums, setLoadingForums] = useState(true)
|
||||
const [loadingThreads, setLoadingThreads] = useState(true)
|
||||
const [loadingStats, setLoadingStats] = useState(true)
|
||||
const [profile, setProfile] = useState(null)
|
||||
const { token, roles, email } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingForums(false))
|
||||
}, [])
|
||||
let active = true
|
||||
setLoadingForums(true)
|
||||
setLoadingThreads(true)
|
||||
setLoadingStats(true)
|
||||
setError('')
|
||||
|
||||
useEffect(() => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingThreads(false))
|
||||
}, [])
|
||||
fetchPortalSummary()
|
||||
.then((data) => {
|
||||
if (!active) return
|
||||
setForums(data?.forums || [])
|
||||
setThreads(data?.threads || [])
|
||||
setStats({
|
||||
threads: data?.stats?.threads ?? 0,
|
||||
posts: data?.stats?.posts ?? 0,
|
||||
users: data?.stats?.users ?? 0,
|
||||
})
|
||||
setProfile(data?.profile || null)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return
|
||||
setError(err.message)
|
||||
setForums([])
|
||||
setThreads([])
|
||||
setStats({ threads: 0, posts: 0, users: 0 })
|
||||
setProfile(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoadingForums(false)
|
||||
setLoadingThreads(false)
|
||||
setLoadingStats(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
@@ -79,6 +110,13 @@ export default function Home() {
|
||||
.slice(0, 12)
|
||||
}, [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) => {
|
||||
if (!thread?.forum) return t('portal.unknown_forum')
|
||||
const parts = thread.forum.split('/')
|
||||
@@ -107,7 +145,7 @@ export default function Home() {
|
||||
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
|
||||
{node.name}
|
||||
</Link>
|
||||
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
|
||||
<div className="bb-muted">{node.description || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +156,7 @@ export default function Home() {
|
||||
))
|
||||
|
||||
return (
|
||||
<Container className="pb-4 bb-portal-shell">
|
||||
<Container fluid className="pb-4 bb-portal-shell">
|
||||
<div className="bb-portal-layout">
|
||||
<aside className="bb-portal-column bb-portal-column--left">
|
||||
<div className="bb-portal-card">
|
||||
@@ -134,11 +172,15 @@ export default function Home() {
|
||||
<div className="bb-portal-card-title">{t('portal.stats')}</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_threads')}</span>
|
||||
<strong>{threads.length}</strong>
|
||||
<strong>{loadingStats ? '—' : stats.threads}</strong>
|
||||
</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_forums')}</span>
|
||||
<strong>{forums.length}</strong>
|
||||
<span>{t('portal.stat_users')}</span>
|
||||
<strong>{loadingStats ? '—' : stats.users}</strong>
|
||||
</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_posts')}</span>
|
||||
<strong>{loadingStats ? '—' : stats.posts}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -159,42 +201,12 @@ export default function Home() {
|
||||
<span>{t('thread.last_post')}</span>
|
||||
</div>
|
||||
{recentThreads.map((thread) => (
|
||||
<div className="bb-portal-topic-row" key={thread.id}>
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
<Badge bg="secondary">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</Badge>
|
||||
<span className="bb-portal-topic-forum">
|
||||
{t('portal.forum_label')}{' '}
|
||||
{resolveForumId(thread) ? (
|
||||
<Link
|
||||
to={`/forum/${resolveForumId(thread)}`}
|
||||
className="bb-portal-topic-forum-link"
|
||||
>
|
||||
{resolveForumName(thread)}
|
||||
</Link>
|
||||
) : (
|
||||
resolveForumName(thread)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">0</div>
|
||||
<div className="bb-portal-topic-cell">—</div>
|
||||
<div className="bb-portal-topic-cell">
|
||||
{thread.created_at?.slice(0, 10) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<PortalTopicRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
forumName={resolveForumName(thread)}
|
||||
forumId={resolveForumId(thread)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -205,9 +217,23 @@ export default function Home() {
|
||||
<div className="bb-portal-card">
|
||||
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
||||
<div className="bb-portal-user-card">
|
||||
<div className="bb-portal-user-avatar" />
|
||||
<div className="bb-portal-user-name">tracer</div>
|
||||
<div className="bb-portal-user-role">Operator</div>
|
||||
<Link to="/ucp" className="bb-portal-user-avatar">
|
||||
{profile?.avatar_url ? (
|
||||
<img src={profile.avatar_url} alt="" />
|
||||
) : (
|
||||
<i className="bi bi-person" aria-hidden="true" />
|
||||
)}
|
||||
</Link>
|
||||
<div className="bb-portal-user-name">
|
||||
{profile?.id ? (
|
||||
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
|
||||
{profile?.name || email || 'User'}
|
||||
</Link>
|
||||
) : (
|
||||
profile?.name || email || 'User'
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-portal-user-role">{roleLabel}</div>
|
||||
</div>
|
||||
<ul className="bb-portal-list">
|
||||
<li>{t('portal.user_new_posts')}</li>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginValue, setLoginValue] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -18,7 +18,7 @@ export default function Login() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
await login(loginValue, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
@@ -28,7 +28,7 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<Container fluid className="py-5">
|
||||
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
||||
<Card.Body>
|
||||
<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>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Label>{t('auth.login_identifier')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
type="text"
|
||||
value={loginValue}
|
||||
onChange={(event) => setLoginValue(event.target.value)}
|
||||
placeholder={t('auth.login_placeholder')}
|
||||
required
|
||||
/>
|
||||
</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 (
|
||||
<Container className="py-5">
|
||||
<Container fluid className="py-5">
|
||||
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
||||
<Card.Body>
|
||||
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Container, Form } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createPost, getThread, listPostsByThread } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -15,6 +15,7 @@ export default function ThreadView() {
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const replyRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -27,6 +28,18 @@ export default function ThreadView() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread && posts.length === 0) return
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
const targetId = hash.replace('#', '')
|
||||
if (!targetId) return
|
||||
const target = document.getElementById(targetId)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [thread, posts])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -43,46 +56,198 @@ export default function ThreadView() {
|
||||
}
|
||||
}
|
||||
|
||||
const replyCount = posts.length
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
const allPosts = useMemo(() => {
|
||||
if (!thread) return posts
|
||||
const rootPost = {
|
||||
id: `thread-${thread.id}`,
|
||||
body: thread.body,
|
||||
created_at: thread.created_at,
|
||||
user_name: thread.user_name,
|
||||
user_avatar_url: thread.user_avatar_url,
|
||||
user_posts_count: thread.user_posts_count,
|
||||
user_created_at: thread.user_created_at,
|
||||
user_location: thread.user_location,
|
||||
user_rank_name: thread.user_rank_name,
|
||||
user_rank_badge_type: thread.user_rank_badge_type,
|
||||
user_rank_badge_text: thread.user_rank_badge_text,
|
||||
user_rank_badge_url: thread.user_rank_badge_url,
|
||||
isRoot: true,
|
||||
}
|
||||
return [rootPost, ...posts]
|
||||
}, [posts, thread])
|
||||
|
||||
const handleJumpToReply = () => {
|
||||
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const totalPosts = allPosts.length
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<Container fluid className="py-4 bb-shell-container">
|
||||
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{thread && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">{t('thread.label')}</p>
|
||||
<h2 className="mt-3">{thread.title}</h2>
|
||||
<p className="bb-muted mb-2">{thread.body}</p>
|
||||
{thread.forum && (
|
||||
<p className="bb-muted mb-0">
|
||||
{t('thread.category')}{' '}
|
||||
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
|
||||
{thread.forum.name || t('thread.back_to_category')}
|
||||
</Link>
|
||||
</p>
|
||||
<div className="bb-thread">
|
||||
<div className="bb-thread-titlebar">
|
||||
<h1 className="bb-thread-title">{thread.title}</h1>
|
||||
<div className="bb-thread-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
<span className="bb-thread-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
|
||||
{posts.length === 0 && (
|
||||
<p className="bb-muted">{t('thread.empty')}</p>
|
||||
<div className="bb-thread-toolbar">
|
||||
<div className="bb-thread-actions">
|
||||
<Button className="bb-accent-button" onClick={handleJumpToReply}>
|
||||
<i className="bi bi-reply-fill" aria-hidden="true" />
|
||||
<span>{t('form.post_reply')}</span>
|
||||
</Button>
|
||||
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
|
||||
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
|
||||
<i className="bi bi-wrench" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
|
||||
<i className="bi bi-gear" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="bb-thread-meta-right">
|
||||
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
|
||||
<span>•</span>
|
||||
<span>Page 1 of 1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bb-posts">
|
||||
{allPosts.map((post, index) => {
|
||||
const authorName = post.author?.username
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
: `${t('thread.reply_prefix')} ${thread.title}`
|
||||
: ''
|
||||
const postNumber = index + 1
|
||||
|
||||
return (
|
||||
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
|
||||
<aside className="bb-post-author">
|
||||
<div className="bb-post-avatar">
|
||||
{post.user_avatar_url ? (
|
||||
<img src={post.user_avatar_url} alt="" />
|
||||
) : (
|
||||
<i className="bi bi-person" aria-hidden="true" />
|
||||
)}
|
||||
{posts.map((post) => (
|
||||
<Card className="bb-card mb-3" key={post.id}>
|
||||
<Card.Body>
|
||||
<Card.Text>{post.body}</Card.Text>
|
||||
<small className="bb-muted">
|
||||
{post.author?.username || t('thread.anonymous')}
|
||||
</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4>
|
||||
<div className="bb-form">
|
||||
</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-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">{t('thread.location')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_location || '-'}
|
||||
</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>
|
||||
</aside>
|
||||
<div className="bb-post-content">
|
||||
<div className="bb-post-header">
|
||||
<div className="bb-post-header-meta">
|
||||
{topicLabel && (
|
||||
<span className="bb-post-topic">
|
||||
#{postNumber} {topicLabel}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('thread.by')} {authorName}</span>
|
||||
{post.created_at && (
|
||||
<span>{post.created_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</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>
|
||||
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-house-door" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-post-body">{post.body}</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bb-thread-reply" ref={replyRef}>
|
||||
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
|
||||
{!token && (
|
||||
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
||||
)}
|
||||
@@ -91,7 +256,7 @@ export default function ThreadView() {
|
||||
<Form.Label>{t('form.message')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
rows={6}
|
||||
placeholder={t('form.reply_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
@@ -99,14 +264,14 @@ export default function ThreadView() {
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
||||
<div className="bb-thread-reply-actions">
|
||||
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
||||
{saving ? t('form.posting') : t('form.post_reply')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import { Container, Form, Row, Col } from 'react-bootstrap'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Container, Form, Row, Col, Button } from 'react-bootstrap'
|
||||
import { getCurrentUser, updateCurrentUser, uploadAvatar } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { token } = useAuth()
|
||||
const accentMode = accentOverride ? 'custom' : 'system'
|
||||
const [avatarError, setAvatarError] = useState('')
|
||||
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||
const [avatarPreview, setAvatarPreview] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [profileError, setProfileError] = useState('')
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
const [profileSaved, setProfileSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
let active = true
|
||||
|
||||
getCurrentUser()
|
||||
.then((data) => {
|
||||
if (!active) return
|
||||
setAvatarPreview(data?.avatar_url || '')
|
||||
setLocation(data?.location || '')
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setAvatarPreview('')
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const locale = event.target.value
|
||||
@@ -12,7 +42,85 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
|
||||
}
|
||||
|
||||
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>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('ucp.location_label')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={location}
|
||||
disabled={!token || profileSaving}
|
||||
onChange={(event) => {
|
||||
setLocation(event.target.value)
|
||||
setProfileSaved(false)
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="bb-muted">{t('ucp.location_hint')}</Form.Text>
|
||||
</Form.Group>
|
||||
{profileError && <p className="text-danger mt-2 mb-0">{profileError}</p>}
|
||||
{profileSaved && <p className="text-success mt-2 mb-0">{t('ucp.profile_saved')}</p>}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-light"
|
||||
className="mt-3"
|
||||
disabled={!token || profileSaving}
|
||||
onClick={async () => {
|
||||
setProfileError('')
|
||||
setProfileSaved(false)
|
||||
setProfileSaving(true)
|
||||
try {
|
||||
const response = await updateCurrentUser({ location })
|
||||
setLocation(response?.location || '')
|
||||
setProfileSaved(true)
|
||||
} catch (err) {
|
||||
setProfileError(err.message)
|
||||
} finally {
|
||||
setProfileSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<div className="bb-portal-card">
|
||||
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
|
||||
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"acp.show_header_name": "Forenname im Header anzeigen",
|
||||
"acp.add_category": "Kategorie hinzufügen",
|
||||
"acp.add_forum": "Forum hinzufügen",
|
||||
"acp.ranks": "Ränge",
|
||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||
"acp.forums_tree": "Forenbaum",
|
||||
"acp.forums_type": "Typ",
|
||||
@@ -60,6 +61,8 @@
|
||||
"acp.users": "Benutzer",
|
||||
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
||||
"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.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||
"auth.register_title": "Konto erstellen",
|
||||
@@ -87,8 +90,7 @@
|
||||
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
|
||||
"forum.loading": "Forum wird geladen...",
|
||||
"forum.login_hint": "Melde dich an, um einen neuen Thread zu erstellen.",
|
||||
"forum.no_description": "Noch keine Beschreibung vorhanden.",
|
||||
"forum.only_forums": "Threads können nur in Foren erstellt werden.",
|
||||
"forum.only_forums": "Threads können nur in Foren erstellt werden.",
|
||||
"forum.open": "Forum öffnen",
|
||||
"forum.collapse_category": "Kategorie einklappen",
|
||||
"forum.expand_category": "Kategorie ausklappen",
|
||||
@@ -99,6 +101,22 @@
|
||||
"user.id": "ID",
|
||||
"user.name": "Name",
|
||||
"user.email": "E-Mail",
|
||||
"user.rank": "Rang",
|
||||
"user.rank_unassigned": "Nicht zugewiesen",
|
||||
"user.edit_title": "Benutzer bearbeiten",
|
||||
"user.search": "Benutzer suchen...",
|
||||
"rank.name": "Rangname",
|
||||
"rank.name_placeholder": "z. B. Operator",
|
||||
"rank.create": "Rang erstellen",
|
||||
"rank.edit_title": "Rang bearbeiten",
|
||||
"rank.badge_type": "Badge-Typ",
|
||||
"rank.badge_text": "Text-Badge",
|
||||
"rank.badge_image": "Bild-Badge",
|
||||
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
|
||||
"rank.badge_text_required": "Badge-Text ist erforderlich.",
|
||||
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
|
||||
"rank.delete_confirm": "Diesen Rang löschen?",
|
||||
"rank.empty": "Noch keine Ränge vorhanden.",
|
||||
"user.roles": "Rollen",
|
||||
"user.actions": "Aktionen",
|
||||
"user.impersonate": "Imitieren",
|
||||
@@ -136,10 +154,12 @@
|
||||
"portal.menu_rules": "Forenregeln",
|
||||
"portal.stats": "Statistik",
|
||||
"portal.stat_threads": "Themen",
|
||||
"portal.stat_forums": "Foren",
|
||||
"portal.stat_users": "Benutzer",
|
||||
"portal.stat_posts": "Beiträge",
|
||||
"portal.latest_posts": "Aktuelle Beiträge",
|
||||
"portal.empty_posts": "Noch keine Beiträge.",
|
||||
"portal.topic": "Themen",
|
||||
"portal.posted_by": "Verfasst von",
|
||||
"portal.forum_label": "Forum:",
|
||||
"portal.unknown_forum": "Unbekannt",
|
||||
"portal.user_menu": "Benutzer-Menü",
|
||||
@@ -148,8 +168,22 @@
|
||||
"portal.user_control_panel": "Benutzerkontrollzentrum",
|
||||
"portal.user_profile": "Profil",
|
||||
"portal.user_logout": "Logout",
|
||||
"portal.user_role_operator": "Operator",
|
||||
"portal.user_role_moderator": "Moderator",
|
||||
"portal.user_role_member": "Mitglied",
|
||||
"portal.advertisement": "Werbung",
|
||||
"profile.title": "Profil",
|
||||
"profile.loading": "Profil wird geladen...",
|
||||
"profile.registered": "Registriert:",
|
||||
"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.location_label": "Wohnort",
|
||||
"ucp.location_hint": "Wird neben Deinen Beiträgen und im Profil angezeigt.",
|
||||
"ucp.save_profile": "Profil speichern",
|
||||
"ucp.profile_saved": "Profil gespeichert.",
|
||||
"ucp.system_default": "Systemstandard",
|
||||
"ucp.accent_override": "Akzentfarbe überschreiben",
|
||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||
@@ -161,6 +195,10 @@
|
||||
"thread.label": "Thread",
|
||||
"thread.loading": "Thread wird geladen...",
|
||||
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
||||
"thread.posts": "Beiträge",
|
||||
"thread.location": "Wohnort",
|
||||
"thread.reply_prefix": "Aw:",
|
||||
"thread.registered": "Registriert",
|
||||
"thread.replies": "Antworten",
|
||||
"thread.views": "Zugriffe",
|
||||
"thread.last_post": "Letzter Beitrag",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"acp.show_header_name": "Display name in header",
|
||||
"acp.add_category": "Add category",
|
||||
"acp.add_forum": "Add forum",
|
||||
"acp.ranks": "Ranks",
|
||||
"acp.forums_parent_root": "Root (no parent)",
|
||||
"acp.forums_tree": "Forum tree",
|
||||
"acp.forums_type": "Type",
|
||||
@@ -60,6 +61,8 @@
|
||||
"acp.users": "Users",
|
||||
"auth.login_hint": "Access your account to start new threads and reply.",
|
||||
"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.verify_notice": "Check your email to verify your account before logging in.",
|
||||
"auth.register_title": "Create account",
|
||||
@@ -87,7 +90,6 @@
|
||||
"forum.empty_threads": "No threads here yet. Start one below.",
|
||||
"forum.loading": "Loading forum...",
|
||||
"forum.login_hint": "Log in to create a new thread.",
|
||||
"forum.no_description": "No description added yet.",
|
||||
"forum.only_forums": "Threads can only be created in forums.",
|
||||
"forum.open": "Open forum",
|
||||
"forum.collapse_category": "Collapse category",
|
||||
@@ -99,6 +101,22 @@
|
||||
"user.id": "ID",
|
||||
"user.name": "Name",
|
||||
"user.email": "Email",
|
||||
"user.rank": "Rank",
|
||||
"user.rank_unassigned": "Unassigned",
|
||||
"user.edit_title": "Edit user",
|
||||
"user.search": "Search users...",
|
||||
"rank.name": "Rank name",
|
||||
"rank.name_placeholder": "e.g. Operator",
|
||||
"rank.create": "Create rank",
|
||||
"rank.edit_title": "Edit rank",
|
||||
"rank.badge_type": "Badge type",
|
||||
"rank.badge_text": "Text badge",
|
||||
"rank.badge_image": "Image badge",
|
||||
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
|
||||
"rank.badge_text_required": "Badge text is required.",
|
||||
"rank.badge_image_required": "Badge image is required.",
|
||||
"rank.delete_confirm": "Delete this rank?",
|
||||
"rank.empty": "No ranks created yet.",
|
||||
"user.roles": "Roles",
|
||||
"user.actions": "Actions",
|
||||
"user.impersonate": "Impersonate",
|
||||
@@ -136,10 +154,12 @@
|
||||
"portal.menu_rules": "Forum rules",
|
||||
"portal.stats": "Statistics",
|
||||
"portal.stat_threads": "Threads",
|
||||
"portal.stat_forums": "Forums",
|
||||
"portal.stat_users": "Users",
|
||||
"portal.stat_posts": "Posts",
|
||||
"portal.latest_posts": "Latest posts",
|
||||
"portal.empty_posts": "No posts yet.",
|
||||
"portal.topic": "Topics",
|
||||
"portal.posted_by": "Posted by",
|
||||
"portal.forum_label": "Forum:",
|
||||
"portal.unknown_forum": "Unknown",
|
||||
"portal.user_menu": "User menu",
|
||||
@@ -148,8 +168,22 @@
|
||||
"portal.user_control_panel": "User Control Panel",
|
||||
"portal.user_profile": "Profile",
|
||||
"portal.user_logout": "Logout",
|
||||
"portal.user_role_operator": "Operator",
|
||||
"portal.user_role_moderator": "Moderator",
|
||||
"portal.user_role_member": "Member",
|
||||
"portal.advertisement": "Advertisement",
|
||||
"profile.title": "Profile",
|
||||
"profile.loading": "Loading profile...",
|
||||
"profile.registered": "Registered:",
|
||||
"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.location_label": "Location",
|
||||
"ucp.location_hint": "Shown next to your posts and profile.",
|
||||
"ucp.save_profile": "Save profile",
|
||||
"ucp.profile_saved": "Profile saved.",
|
||||
"ucp.system_default": "System default",
|
||||
"ucp.accent_override": "Accent color override",
|
||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||
@@ -161,6 +195,10 @@
|
||||
"thread.label": "Thread",
|
||||
"thread.loading": "Loading thread...",
|
||||
"thread.login_hint": "Log in to reply to this thread.",
|
||||
"thread.posts": "Posts",
|
||||
"thread.location": "Location",
|
||||
"thread.reply_prefix": "Re:",
|
||||
"thread.registered": "Registered",
|
||||
"thread.replies": "Replies",
|
||||
"thread.views": "Views",
|
||||
"thread.last_post": "Last post",
|
||||
|
||||
@@ -3,20 +3,31 @@
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\ForumController;
|
||||
use App\Http\Controllers\I18nController;
|
||||
use App\Http\Controllers\PortalController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Http\Controllers\StatsController;
|
||||
use App\Http\Controllers\ThreadController;
|
||||
use App\Http\Controllers\UploadController;
|
||||
use App\Http\Controllers\UserSettingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\VersionController;
|
||||
use App\Http\Controllers\RankController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
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('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
||||
|
||||
Route::get('/version', VersionController::class);
|
||||
Route::get('/portal/summary', PortalController::class);
|
||||
Route::get('/stats', StatsController::class);
|
||||
Route::get('/settings', [SettingController::class, 'index']);
|
||||
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
||||
@@ -24,8 +35,19 @@ Route::get('/user-settings', [UserSettingController::class, 'index'])->middlewar
|
||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->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('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
|
||||
Route::get('/i18n/{locale}', I18nController::class);
|
||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
||||
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
|
||||
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
|
||||
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
|
||||
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
|
||||
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
|
||||
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
|
||||
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum');
|
||||
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
||||
|
||||
Route::get('/forums', [ForumController::class, 'index']);
|
||||
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'app');
|
||||
Route::view('/login', 'app')->name('login');
|
||||
Route::view('/reset-password', 'app')->name('password.reset');
|
||||
|
||||
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Reference in New Issue
Block a user