Compare commits
13 Commits
laravel
...
4c2468952c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2468952c | ||
|
|
073c81012b | ||
|
|
24c16ed0dd | ||
|
|
f9de433545 | ||
|
|
fd29b928d8 | ||
|
|
98094459e3 | ||
|
|
3bb2946656 | ||
|
|
bbbf8eb6c1 | ||
|
|
c8d2bd508e | ||
| eef3262a53 | |||
| fe1015bff1 | |||
|
|
8604cdf95d | ||
| f83748cc76 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
._*
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -20,8 +21,11 @@
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*.php
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -1,5 +1,62 @@
|
||||
# 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.
|
||||
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
|
||||
|
||||
## 2025-12-30
|
||||
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
|
||||
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
|
||||
- Added thread seeding for forum test data.
|
||||
- Enforced category-only roots for forums (API validation, UI, and database constraint).
|
||||
- Added portal header with phpBB-style breadcrumb + quick links, plus notifications/messages + user menu.
|
||||
- Replaced the home page with a portal-style layout and latest posts list.
|
||||
- Added a dedicated board index page with phpBB-like sections and per-category collapse toggles.
|
||||
- Persisted board index collapse state per user via user_settings (DB + API + client cache).
|
||||
- Added category view rendering for subcategories in ForumView.
|
||||
- Updated thread list UI (icons, spacing) and New topic button styling in ForumView.
|
||||
- Added ACP per-category quick-create buttons for child categories and forums.
|
||||
- Removed the legacy navbar and cleaned up related styling.
|
||||
|
||||
## 2025-12-29
|
||||
- Merged the React app into the Laravel codebase under `resources/js`.
|
||||
- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders.
|
||||
- Serve the SPA shell via Blade with Vite-managed React assets.
|
||||
- Dropped Tailwind tooling to keep the frontend Bootstrap-only.
|
||||
- Replaced the default Laravel README with a forum placeholder.
|
||||
- Updated ACP forum tools with accent-tinted buttons and larger action spacing.
|
||||
- Defaulted the ACP forum tree to collapsed on page load.
|
||||
- Improved ACP create/edit dialog copy based on forum vs category and hid type selection during creation.
|
||||
|
||||
## 2025-12-26
|
||||
- Replaced the Symfony backend with a fresh Laravel app in `api/`.
|
||||
- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version.
|
||||
- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent).
|
||||
- Added Laravel i18n endpoint backed by JSON translation files.
|
||||
- Updated frontend auth/register flow to work with Laravel tokens.
|
||||
|
||||
## 2025-12-24
|
||||
- Reworked the domain model into a single forum tree (category/forum types) with parent/child hierarchy and threads restricted to forum nodes.
|
||||
- Updated API Platform resources, filters, migrations, and JSON format support.
|
||||
@@ -22,16 +79,3 @@
|
||||
- Improved ACP drag-and-drop hover reordering and visual drop target feedback.
|
||||
- Hardened ACP access so admin tools require authentication.
|
||||
- Updated the home page to render the forum tree with ACP-style rows and icons.
|
||||
|
||||
## 2025-12-26
|
||||
- Replaced the Symfony backend with a fresh Laravel app in `api/`.
|
||||
- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version.
|
||||
- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent).
|
||||
- Added Laravel i18n endpoint backed by JSON translation files.
|
||||
- Updated frontend auth/register flow to work with Laravel tokens.
|
||||
|
||||
## 2025-12-29
|
||||
- Merged the React app into the Laravel codebase under `resources/js`.
|
||||
- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders.
|
||||
- Serve the SPA shell via Blade with Vite-managed React assets.
|
||||
- Dropped Tailwind tooling to keep the frontend Bootstrap-only.
|
||||
|
||||
60
README.md
60
README.md
@@ -1,59 +1,7 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
# SpeedBB Forum
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
Placeholder README for the forum application.
|
||||
|
||||
## About Laravel
|
||||
## Status
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
Work in progress.
|
||||
|
||||
@@ -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,22 +20,31 @@ class CreateNewUser implements CreatesNewUsers
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
$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',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
Rule::unique(table: User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
return User::create(attributes: [
|
||||
'name' => $input['name'],
|
||||
'name_canonical' => $input['name_canonical'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'password' => Hash::make(value: $input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
@@ -9,7 +10,7 @@ trait PasswordValidationRules
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
@@ -29,12 +38,12 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'name_canonical' => $input['name_canonical'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
@@ -49,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,8 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -11,39 +13,51 @@ class ForumController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Forum::query();
|
||||
$query = Forum::query()
|
||||
->withoutTrashed()
|
||||
->withCount(relations: ['threads', 'posts'])
|
||||
->withSum(relation: 'threads', column: 'views_count');
|
||||
|
||||
$parentParam = $request->query('parent');
|
||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$parentParam = $request->query(key: 'parent');
|
||||
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||
if ($exists === false) {
|
||||
$query->whereNull('parent_id');
|
||||
$query->whereNull(columns: 'parent_id');
|
||||
} elseif ($exists === true) {
|
||||
$query->whereNotNull('parent_id');
|
||||
$query->whereNotNull(columns: 'parent_id');
|
||||
}
|
||||
} elseif (is_string($parentParam)) {
|
||||
$parentId = $this->parseIriId($parentParam);
|
||||
} elseif (is_string(value: $parentParam)) {
|
||||
$parentId = $this->parseIriId(value: $parentParam);
|
||||
if ($parentId !== null) {
|
||||
$query->where('parent_id', $parentId);
|
||||
$query->where(column: 'parent_id', operator: $parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', $request->query('type'));
|
||||
if ($request->filled(key: 'type')) {
|
||||
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||
}
|
||||
|
||||
$forums = $query
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Forum $forum) => $this->serializeForum($forum));
|
||||
->orderBy(column: 'position')
|
||||
->orderBy(column: 'name')
|
||||
->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
|
||||
@@ -57,6 +71,10 @@ class ForumController extends Controller
|
||||
|
||||
$parentId = $this->parseIriId($data['parent'] ?? null);
|
||||
|
||||
if ($data['type'] === 'forum' && !$parentId) {
|
||||
return response()->json(['message' => 'Forums must belong to a category.'], 422);
|
||||
}
|
||||
|
||||
if ($parentId) {
|
||||
$parent = Forum::findOrFail($parentId);
|
||||
if ($parent->type !== 'category') {
|
||||
@@ -64,7 +82,12 @@ class ForumController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||
if ($parentId === null) {
|
||||
Forum::whereNull('parent_id')->increment('position');
|
||||
$position = 0;
|
||||
} else {
|
||||
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||
}
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => $data['name'],
|
||||
@@ -74,7 +97,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
|
||||
@@ -87,6 +114,12 @@ class ForumController extends Controller
|
||||
]);
|
||||
|
||||
$parentId = $this->parseIriId($data['parent'] ?? null);
|
||||
$nextType = $data['type'] ?? $forum->type;
|
||||
$nextParentId = array_key_exists('parent', $data) ? $parentId : $forum->parent_id;
|
||||
|
||||
if ($nextType === 'forum' && !$nextParentId) {
|
||||
return response()->json(['message' => 'Forums must belong to a category.'], 422);
|
||||
}
|
||||
|
||||
if (array_key_exists('parent', $data)) {
|
||||
if ($parentId) {
|
||||
@@ -112,11 +145,17 @@ 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(Forum $forum): JsonResponse
|
||||
public function destroy(Request $request, Forum $forum): JsonResponse
|
||||
{
|
||||
$forum->deleted_by = $request->user()?->id;
|
||||
$forum->save();
|
||||
$forum->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
@@ -163,7 +202,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,
|
||||
@@ -172,8 +211,76 @@ 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,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'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.rank', 'user.roles'])
|
||||
->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(column: 'posts.created_at')
|
||||
->with(relations: ['user.rank', 'user.roles'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy(callback: 'name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
140
app/Http/Controllers/InstallerController.php
Normal file
140
app/Http/Controllers/InstallerController.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InstallerController extends Controller
|
||||
{
|
||||
public function show(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($this->envExists()) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return view('installer', [
|
||||
'appUrl' => $request->getSchemeAndHttpHost(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($this->envExists()) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'app_url' => ['required', 'url'],
|
||||
'db_host' => ['required', 'string', 'max:255'],
|
||||
'db_port' => ['nullable', 'integer'],
|
||||
'db_database' => ['required', 'string', 'max:255'],
|
||||
'db_username' => ['required', 'string', 'max:255'],
|
||||
'db_password' => ['nullable', 'string'],
|
||||
'admin_name' => ['required', 'string', 'max:255'],
|
||||
'admin_email' => ['required', 'email', 'max:255'],
|
||||
'admin_password' => ['required', 'string', 'min:8'],
|
||||
]);
|
||||
|
||||
$appKey = 'base64:' . base64_encode(random_bytes(32));
|
||||
|
||||
$envLines = [
|
||||
'APP_NAME="speedBB"',
|
||||
'APP_ENV=production',
|
||||
'APP_DEBUG=false',
|
||||
'APP_URL=' . $data['app_url'],
|
||||
'APP_KEY=' . $appKey,
|
||||
'',
|
||||
'DB_CONNECTION=mysql',
|
||||
'DB_HOST=' . $data['db_host'],
|
||||
'DB_PORT=' . ($data['db_port'] ?: 3306),
|
||||
'DB_DATABASE=' . $data['db_database'],
|
||||
'DB_USERNAME=' . $data['db_username'],
|
||||
'DB_PASSWORD=' . ($data['db_password'] ?? ''),
|
||||
'',
|
||||
'MAIL_MAILER=sendmail',
|
||||
'MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"',
|
||||
'MAIL_FROM_ADDRESS="hello@example.com"',
|
||||
'MAIL_FROM_NAME="speedBB"',
|
||||
];
|
||||
|
||||
$this->writeEnv(implode("\n", $envLines) . "\n");
|
||||
|
||||
config([
|
||||
'app.key' => $appKey,
|
||||
'app.url' => $data['app_url'],
|
||||
'database.default' => 'mysql',
|
||||
'database.connections.mysql.host' => $data['db_host'],
|
||||
'database.connections.mysql.port' => (int) ($data['db_port'] ?: 3306),
|
||||
'database.connections.mysql.database' => $data['db_database'],
|
||||
'database.connections.mysql.username' => $data['db_username'],
|
||||
'database.connections.mysql.password' => $data['db_password'] ?? '',
|
||||
'mail.default' => 'sendmail',
|
||||
'mail.mailers.sendmail.path' => '/usr/sbin/sendmail -bs -i',
|
||||
]);
|
||||
|
||||
DB::purge('mysql');
|
||||
|
||||
try {
|
||||
DB::connection('mysql')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->removeEnv();
|
||||
return view('installer', [
|
||||
'appUrl' => $data['app_url'],
|
||||
'error' => 'Database connection failed: ' . $e->getMessage(),
|
||||
'old' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
$migrateExit = Artisan::call('migrate', ['--force' => true]);
|
||||
if ($migrateExit !== 0) {
|
||||
$this->removeEnv();
|
||||
return view('installer', [
|
||||
'appUrl' => $data['app_url'],
|
||||
'error' => 'Migration failed. Please check your database credentials.',
|
||||
'old' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
$adminRole = Role::firstOrCreate(['name' => 'ROLE_ADMIN']);
|
||||
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER']);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['admin_name'],
|
||||
'name_canonical' => Str::lower(trim($data['admin_name'])),
|
||||
'email' => $data['admin_email'],
|
||||
'password' => Hash::make($data['admin_password']),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$user->roles()->sync([$adminRole->id, $founderRole->id]);
|
||||
|
||||
return view('installer-success');
|
||||
}
|
||||
|
||||
private function envExists(): bool
|
||||
{
|
||||
return file_exists(base_path('.env'));
|
||||
}
|
||||
|
||||
private function writeEnv(string $contents): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
file_put_contents($path, $contents);
|
||||
}
|
||||
|
||||
private function removeEnv(): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
180
app/Http/Controllers/PortalController.php
Normal file
180
app/Http/Controllers/PortalController.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])
|
||||
->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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
] : 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,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'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,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'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,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'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.rank', 'user.roles'])
|
||||
->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 resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,17 @@ 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();
|
||||
$query = Post::query()->withoutTrashed()->with([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
if (is_string($threadParam)) {
|
||||
@@ -45,11 +50,19 @@ class PostController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
}
|
||||
|
||||
public function destroy(Post $post): JsonResponse
|
||||
public function destroy(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$post->deleted_by = $request->user()?->id;
|
||||
$post->save();
|
||||
$post->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
@@ -79,8 +92,45 @@ 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_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
|
||||
'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,
|
||||
'user_rank_color' => $post->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
122
app/Http/Controllers/PostThankController.php
Normal file
122
app/Http/Controllers/PostThankController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostThank;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PostThankController extends Controller
|
||||
{
|
||||
public function store(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$thank = PostThank::firstOrCreate([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $thank->id,
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
PostThank::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function given(User $user): JsonResponse
|
||||
{
|
||||
$thanks = PostThank::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['post.thread', 'post.user.rank', 'post.user.roles'])
|
||||
->latest('created_at')
|
||||
->get()
|
||||
->map(fn (PostThank $thank) => $this->serializeGiven($thank));
|
||||
|
||||
return response()->json($thanks);
|
||||
}
|
||||
|
||||
public function received(User $user): JsonResponse
|
||||
{
|
||||
$thanks = PostThank::query()
|
||||
->whereHas('post', fn ($query) => $query->where('user_id', $user->id))
|
||||
->with(['post.thread', 'user.rank', 'user.roles'])
|
||||
->latest('created_at')
|
||||
->get()
|
||||
->map(fn (PostThank $thank) => $this->serializeReceived($thank));
|
||||
|
||||
return response()->json($thanks);
|
||||
}
|
||||
|
||||
private function serializeGiven(PostThank $thank): array
|
||||
{
|
||||
return [
|
||||
'id' => $thank->id,
|
||||
'post_id' => $thank->post_id,
|
||||
'thread_id' => $thank->post?->thread_id,
|
||||
'thread_title' => $thank->post?->thread?->title,
|
||||
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
|
||||
'post_author_id' => $thank->post?->user_id,
|
||||
'post_author_name' => $thank->post?->user?->name,
|
||||
'post_author_rank_color' => $thank->post?->user?->rank?->color,
|
||||
'post_author_group_color' => $this->resolveGroupColor($thank->post?->user),
|
||||
'thanked_at' => $thank->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeReceived(PostThank $thank): array
|
||||
{
|
||||
return [
|
||||
'id' => $thank->id,
|
||||
'post_id' => $thank->post_id,
|
||||
'thread_id' => $thank->post?->thread_id,
|
||||
'thread_title' => $thank->post?->thread?->title,
|
||||
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
|
||||
'thanker_id' => $thank->user_id,
|
||||
'thanker_name' => $thank->user?->name,
|
||||
'thanker_rank_color' => $thank->user?->rank?->color,
|
||||
'thanker_group_color' => $this->resolveGroupColor($thank->user),
|
||||
'thanked_at' => $thank->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/RankController.php
Normal file
167
app/Http/Controllers/RankController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?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(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(data: ['message' => 'Forbidden'], status: 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,
|
||||
'color' => $rank->color,
|
||||
'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,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
|
||||
$rank = Rank::create([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $data['color'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'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,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
|
||||
|
||||
if ($badgeType !== 'image' && $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,
|
||||
'color' => $color,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $rank->id,
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
141
app/Http/Controllers/RoleController.php
Normal file
141
app/Http/Controllers/RoleController.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
private const CORE_ROLES = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_FOUNDER'];
|
||||
|
||||
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
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$roles = Role::query()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Role $role) => [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'color' => $role->color,
|
||||
]);
|
||||
|
||||
return response()->json($roles);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:roles,name'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$normalizedName = $this->normalizeRoleName($data['name']);
|
||||
if (Role::query()->where('name', $normalizedName)->exists()) {
|
||||
return response()->json(['message' => 'Role already exists.'], 422);
|
||||
}
|
||||
|
||||
$role = Role::create([
|
||||
'name' => $normalizedName,
|
||||
'color' => $data['color'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'color' => $role->color,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, Role $role): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:roles,name,{$role->id}"],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$normalizedName = $this->normalizeRoleName($data['name']);
|
||||
if (Role::query()
|
||||
->where('id', '!=', $role->id)
|
||||
->where('name', $normalizedName)
|
||||
->exists()
|
||||
) {
|
||||
return response()->json(['message' => 'Role already exists.'], 422);
|
||||
}
|
||||
|
||||
if (in_array($role->name, self::CORE_ROLES, true) && $normalizedName !== $role->name) {
|
||||
return response()->json(['message' => 'Core roles cannot be renamed.'], 422);
|
||||
}
|
||||
|
||||
$color = array_key_exists('color', $data) ? $data['color'] : $role->color;
|
||||
|
||||
$role->update([
|
||||
'name' => $normalizedName,
|
||||
'color' => $color,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'color' => $role->color,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Role $role): JsonResponse
|
||||
{
|
||||
if ($error = $this->ensureAdmin($request)) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if (in_array($role->name, self::CORE_ROLES, true)) {
|
||||
return response()->json(['message' => 'Core roles cannot be deleted.'], 422);
|
||||
}
|
||||
|
||||
if ($role->users()->exists()) {
|
||||
return response()->json(['message' => 'Role is assigned to users.'], 422);
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function normalizeRoleName(string $value): string
|
||||
{
|
||||
$raw = strtoupper(trim($value));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
return 'ROLE_';
|
||||
}
|
||||
if (str_starts_with($raw, 'ROLE_')) {
|
||||
return $raw;
|
||||
}
|
||||
return "ROLE_{$raw}";
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,60 @@ class SettingController extends Controller
|
||||
|
||||
return response()->json($settings);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'key' => ['required', 'string', 'max:191'],
|
||||
'value' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$value = $data['value'] ?? '';
|
||||
|
||||
$setting = Setting::updateOrCreate(
|
||||
['key' => $data['key']],
|
||||
['value' => $value]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'id' => $setting->id,
|
||||
'key' => $setting->key,
|
||||
'value' => $setting->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkStore(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'settings' => ['required', 'array'],
|
||||
'settings.*.key' => ['required', 'string', 'max:191'],
|
||||
'settings.*.value' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$updated = [];
|
||||
|
||||
foreach ($data['settings'] as $entry) {
|
||||
$setting = Setting::updateOrCreate(
|
||||
['key' => $entry['key']],
|
||||
['value' => $entry['value'] ?? '']
|
||||
);
|
||||
$updated[] = [
|
||||
'id' => $setting->id,
|
||||
'key' => $setting->key,
|
||||
'value' => $setting->value,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json($updated);
|
||||
}
|
||||
}
|
||||
|
||||
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,24 @@ use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Thread::query();
|
||||
$query = Thread::query()
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->withMax('posts', 'created_at')
|
||||
->with([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
]);
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
if (is_string($forumParam)) {
|
||||
@@ -22,7 +34,7 @@ class ThreadController extends Controller
|
||||
}
|
||||
|
||||
$threads = $query
|
||||
->latest('created_at')
|
||||
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
||||
->get()
|
||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||
|
||||
@@ -31,6 +43,15 @@ class ThreadController extends Controller
|
||||
|
||||
public function show(Thread $thread): JsonResponse
|
||||
{
|
||||
$thread->increment('views_count');
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
@@ -56,11 +77,21 @@ class ThreadController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread), 201);
|
||||
}
|
||||
|
||||
public function destroy(Thread $thread): JsonResponse
|
||||
public function destroy(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$thread->deleted_by = $request->user()?->id;
|
||||
$thread->save();
|
||||
$thread->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
@@ -91,8 +122,57 @@ 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_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
|
||||
'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,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'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,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
79
app/Http/Controllers/UploadController.php
Normal file
79
app/Http/Controllers/UploadController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
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();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'],
|
||||
]);
|
||||
|
||||
$path = $data['file']->store('logos', 'public');
|
||||
|
||||
return response()->json([
|
||||
'path' => $path,
|
||||
'url' => Storage::url($path),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeFavicon(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'],
|
||||
]);
|
||||
|
||||
$path = $data['file']->store('favicons', 'public');
|
||||
|
||||
return response()->json([
|
||||
'path' => $path,
|
||||
'url' => Storage::url($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,276 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'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);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
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'],
|
||||
'roles' => ['nullable', 'array'],
|
||||
'roles.*' => ['string', 'exists:roles,name'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('roles', $data) && !$this->isFounder($actor)) {
|
||||
$requested = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name));
|
||||
if ($requested->contains('ROLE_FOUNDER')) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
if (array_key_exists('roles', $data)) {
|
||||
$roleNames = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$roleIds = Role::query()
|
||||
->whereIn('name', $roleNames)
|
||||
->pluck('id')
|
||||
->all();
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
|
||||
$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,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveAvatarUrl(User $user): ?string
|
||||
{
|
||||
if (!$user->avatar_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($user->avatar_path);
|
||||
}
|
||||
|
||||
private function resolveGroupColor(User $user): ?string
|
||||
{
|
||||
$user->loadMissing('roles');
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeRoleName(string $value): string
|
||||
{
|
||||
$raw = strtoupper(trim($value));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
return 'ROLE_';
|
||||
}
|
||||
if (str_starts_with($raw, 'ROLE_')) {
|
||||
return $raw;
|
||||
}
|
||||
return "ROLE_{$raw}";
|
||||
}
|
||||
|
||||
private function isFounder(User $user): bool
|
||||
{
|
||||
return $user->roles()->where('name', 'ROLE_FOUNDER')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
47
app/Http/Controllers/UserSettingController.php
Normal file
47
app/Http/Controllers/UserSettingController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserSettingController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$query = UserSetting::query()->where('user_id', $user->id);
|
||||
|
||||
if ($request->filled('key')) {
|
||||
$query->where('key', $request->query('key'));
|
||||
}
|
||||
|
||||
$settings = $query->get()->map(fn (UserSetting $setting) => [
|
||||
'id' => $setting->id,
|
||||
'key' => $setting->key,
|
||||
'value' => $setting->value,
|
||||
]);
|
||||
|
||||
return response()->json($settings);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'key' => ['required', 'string', 'max:191'],
|
||||
'value' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$setting = UserSetting::updateOrCreate(
|
||||
['user_id' => $request->user()->id, 'key' => $data['key']],
|
||||
['value' => $data['value'] ?? []]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'id' => $setting->id,
|
||||
'key' => $setting->key,
|
||||
'value' => $setting->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ 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;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -35,6 +39,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Forum extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
@@ -57,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -27,6 +29,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Post extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'thread_id',
|
||||
'user_id',
|
||||
@@ -42,4 +46,9 @@ class Post extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function thanks(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
}
|
||||
|
||||
24
app/Models/PostThank.php
Normal file
24
app/Models/PostThank.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostThank extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/Rank.php
Normal file
29
app/Models/Rank.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class Role extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
|
||||
@@ -4,7 +4,9 @@ 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;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -32,6 +34,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Thread extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'forum_id',
|
||||
'user_id',
|
||||
@@ -53,4 +57,9 @@ class Thread extends Model
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function latestPost(): HasOne
|
||||
{
|
||||
return $this->hasOne(Post::class)->latestOfMany();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,32 +2,40 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
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\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Notifications\DatabaseNotificationCollection;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property \Illuminate\Support\Carbon|null $email_verified_at
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string|null $two_factor_recovery_codes
|
||||
* @property string|null $two_factor_confirmed_at
|
||||
* @property string|null $remember_token
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
|
||||
* @property-read int|null $notifications_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Role> $roles
|
||||
* @property-read Collection<int, Role> $roles
|
||||
* @property-read int|null $roles_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Sanctum\PersonalAccessToken> $tokens
|
||||
* @property-read Collection<int, PersonalAccessToken> $tokens
|
||||
* @property-read int|null $tokens_count
|
||||
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
|
||||
* @method static UserFactory factory($count = null, $state = [])
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
|
||||
@@ -46,8 +54,10 @@ use Laravel\Sanctum\HasApiTokens;
|
||||
*/
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens;
|
||||
use HasFactory;
|
||||
use Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -56,6 +66,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'name_canonical',
|
||||
'avatar_path',
|
||||
'location',
|
||||
'rank_id',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
@@ -87,4 +101,24 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function thanksGiven(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
|
||||
public function thanksReceived(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id');
|
||||
}
|
||||
|
||||
public function rank()
|
||||
{
|
||||
return $this->belongsTo(Rank::class);
|
||||
}
|
||||
}
|
||||
|
||||
18
app/Models/UserSetting.php
Normal file
18
app/Models/UserSetting.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +20,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Model::preventLazyLoading(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
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,11 +6,12 @@
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"laravel/fortify": "*",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "*",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"ext-pdo": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-ide-helper": "^3.6",
|
||||
@@ -20,7 +21,8 @@
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
"phpunit/phpunit": "^11.5.3",
|
||||
"squizlabs/php_codesniffer": "^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -35,6 +37,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"phpcs": "phpcs",
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
|
||||
81
composer.lock
generated
81
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5b4bf74d07107b744033916f34a83be1",
|
||||
"content-hash": "8e91d3287080a532070e38f61106f41c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -8827,6 +8827,85 @@
|
||||
],
|
||||
"time": "2024-10-09T05:16:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "squizlabs/php_codesniffer",
|
||||
"version": "4.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
|
||||
"reference": "0525c73950de35ded110cffafb9892946d7771b5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5",
|
||||
"reference": "0525c73950de35ded110cffafb9892946d7771b5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-simplexml": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"php": ">=7.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31"
|
||||
},
|
||||
"bin": [
|
||||
"bin/phpcbf",
|
||||
"bin/phpcs"
|
||||
],
|
||||
"type": "library",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Greg Sherwood",
|
||||
"role": "Former lead"
|
||||
},
|
||||
{
|
||||
"name": "Juliette Reinders Folmer",
|
||||
"role": "Current lead"
|
||||
},
|
||||
{
|
||||
"name": "Contributors",
|
||||
"homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.",
|
||||
"homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
|
||||
"keywords": [
|
||||
"phpcs",
|
||||
"standards",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
|
||||
"security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
|
||||
"source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
|
||||
"wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/PHPCSStandards",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/jrfnl",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://opencollective.com/php_codesniffer",
|
||||
"type": "open_collective"
|
||||
},
|
||||
{
|
||||
"url": "https://thanks.dev/u/gh/phpcsstandards",
|
||||
"type": "thanks_dev"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-10T16:43:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "staabm/side-effects-detector",
|
||||
"version": "1.0.5",
|
||||
|
||||
@@ -112,6 +112,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'),
|
||||
|
||||
];
|
||||
|
||||
@@ -59,7 +59,9 @@ return [
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
(PHP_VERSION_ID >= 80500 ?
|
||||
\Pdo\Mysql::ATTR_SSL_CA :
|
||||
\PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
@@ -79,7 +81,9 @@ return [
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
(PHP_VERSION_ID >= 80500 ?
|
||||
\Pdo\Mysql::ATTR_SSL_CA :
|
||||
\PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
@@ -148,7 +152,7 @@ return [
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ return [
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
| based disks, are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
@@ -41,7 +41,7 @@ return [
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL').'/storage',
|
||||
'url' => env('APP_URL') . '/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
|
||||
@@ -89,7 +89,7 @@ return [
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
@@ -46,7 +46,10 @@ return [
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
'local_domain' => env(
|
||||
'MAIL_EHLO_DOMAIN',
|
||||
parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST)
|
||||
),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
|
||||
@@ -129,7 +129,7 @@ return [
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
Str::slug((string) env('APP_NAME', 'laravel')) . '-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -36,12 +36,6 @@ return new class extends Migration
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'key' => 'accent_color',
|
||||
'value' => '#f29b3f',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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('forums', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->index(['deleted_at', 'deleted_by'], 'idx_forums_deleted_meta');
|
||||
});
|
||||
|
||||
Schema::table('threads', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->index(['deleted_at', 'deleted_by'], 'idx_threads_deleted_meta');
|
||||
});
|
||||
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->index(['deleted_at', 'deleted_by'], 'idx_posts_deleted_meta');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('forums', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_forums_deleted_meta');
|
||||
$table->dropConstrainedForeignId('deleted_by');
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
|
||||
Schema::table('threads', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_threads_deleted_meta');
|
||||
$table->dropConstrainedForeignId('deleted_by');
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_posts_deleted_meta');
|
||||
$table->dropConstrainedForeignId('deleted_by');
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('forums')
|
||||
->where('type', 'forum')
|
||||
->whereNull('parent_id')
|
||||
->update(['type' => 'category']);
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_insert');
|
||||
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_update');
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE TRIGGER trg_forums_parent_insert
|
||||
BEFORE INSERT ON forums
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF NEW.type = 'forum' AND NEW.parent_id IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Forums must belong to a category.';
|
||||
END IF;
|
||||
END
|
||||
SQL);
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE TRIGGER trg_forums_parent_update
|
||||
BEFORE UPDATE ON forums
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF NEW.type = 'forum' AND NEW.parent_id IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Forums must belong to a category.';
|
||||
END IF;
|
||||
END
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_insert');
|
||||
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_update');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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::create('user_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('key');
|
||||
$table->json('value');
|
||||
$table->timestamps();
|
||||
$table->unique(['user_id', 'key'], 'uniq_user_settings_user_key');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_settings');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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::create('post_thanks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['post_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('post_thanks');
|
||||
}
|
||||
};
|
||||
@@ -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('ranks', function (Blueprint $table) {
|
||||
$table->string('color', 20)->nullable()->after('badge_image_path');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ranks', function (Blueprint $table) {
|
||||
$table->dropColumn('color');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$roles = DB::table('roles')
|
||||
->select(['id', 'name'])
|
||||
->get();
|
||||
|
||||
foreach ($roles as $role) {
|
||||
$name = (string) $role->name;
|
||||
if (str_starts_with($name, 'ROLE_')) {
|
||||
continue;
|
||||
}
|
||||
$raw = strtoupper(trim($name));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized = str_starts_with($raw, 'ROLE_') ? $raw : "ROLE_{$raw}";
|
||||
|
||||
$exists = DB::table('roles')
|
||||
->where('id', '!=', $role->id)
|
||||
->where('name', $normalized)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('roles')
|
||||
->where('id', $role->id)
|
||||
->update(['name' => $normalized]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No safe reversal.
|
||||
}
|
||||
};
|
||||
@@ -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('roles', function (Blueprint $table) {
|
||||
$table->string('color', 20)->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('color');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -16,8 +16,10 @@ 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]);
|
||||
}
|
||||
}
|
||||
36
database/seeders/ThreadSeeder.php
Normal file
36
database/seeders/ThreadSeeder.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Faker\Factory as FakerFactory;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ThreadSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$faker = FakerFactory::create();
|
||||
$users = User::all();
|
||||
$forums = Forum::where('type', 'forum')->get();
|
||||
|
||||
if ($users->isEmpty() || $forums->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($forums as $forum) {
|
||||
$threadCount = $faker->numberBetween(2, 8);
|
||||
for ($i = 0; $i < $threadCount; $i += 1) {
|
||||
$author = $users->random();
|
||||
Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => ucfirst($faker->words($faker->numberBetween(3, 6), true)),
|
||||
'body' => $faker->paragraphs($faker->numberBetween(2, 4), true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
39
package-lock.json
generated
39
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-data-table-component": "^7.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
@@ -1657,6 +1658,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
@@ -2463,6 +2473,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -3680,6 +3702,23 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-data-table-component": "^7.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
|
||||
18
phpcs.xml
Normal file
18
phpcs.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="speedBB">
|
||||
<description>Project coding standard based on PSR-12.</description>
|
||||
|
||||
<rule ref="PSR12"/>
|
||||
|
||||
<file>app</file>
|
||||
<file>config</file>
|
||||
<file>database</file>
|
||||
<file>routes</file>
|
||||
<file>tests</file>
|
||||
|
||||
<exclude-pattern>bootstrap/cache/*</exclude-pattern>
|
||||
<exclude-pattern>node_modules/*</exclude-pattern>
|
||||
<exclude-pattern>public/build/*</exclude-pattern>
|
||||
<exclude-pattern>storage/*</exclude-pattern>
|
||||
<exclude-pattern>vendor/*</exclude-pattern>
|
||||
</ruleset>
|
||||
@@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php'))
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Allow the installer to run without a .env file.
|
||||
if (!file_exists(__DIR__.'/../.env')) {
|
||||
$tempKey = 'base64:'.base64_encode(random_bytes(32));
|
||||
$_ENV['APP_KEY'] = $tempKey;
|
||||
$_SERVER['APP_KEY'] = $tempKey;
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_SERVER['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
$_SERVER['DB_DATABASE'] = ':memory:';
|
||||
$_ENV['SESSION_DRIVER'] = 'array';
|
||||
$_SERVER['SESSION_DRIVER'] = 'array';
|
||||
$_ENV['SESSION_DOMAIN'] = null;
|
||||
$_SERVER['SESSION_DOMAIN'] = null;
|
||||
$_ENV['SESSION_SECURE_COOKIE'] = false;
|
||||
$_SERVER['SESSION_SECURE_COOKIE'] = false;
|
||||
}
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
|
||||
import { Container, Nav, Navbar, NavDropdown } from 'react-bootstrap'
|
||||
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { Container, NavDropdown } from 'react-bootstrap'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Home from './pages/Home'
|
||||
import ForumView from './pages/ForumView'
|
||||
@@ -8,181 +8,503 @@ import ThreadView from './pages/ThreadView'
|
||||
import Login from './pages/Login'
|
||||
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 { fetchSetting, fetchVersion } from './api/client'
|
||||
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
||||
|
||||
function Navigation({ theme, onThemeChange }) {
|
||||
const { token, email, logout, isAdmin } = useAuth()
|
||||
const { t, i18n } = useTranslation()
|
||||
function PortalHeader({
|
||||
userMenu,
|
||||
isAuthenticated,
|
||||
forumName,
|
||||
logoUrl,
|
||||
showHeaderName,
|
||||
canAccessAcp,
|
||||
canAccessMcp,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const [crumbs, setCrumbs] = useState([])
|
||||
|
||||
const handleLanguageChange = (locale) => {
|
||||
i18n.changeLanguage(locale)
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const handleThemeChange = (value) => {
|
||||
onThemeChange(value)
|
||||
localStorage.setItem('speedbb_theme', value)
|
||||
}
|
||||
const parseForumId = (parent) => {
|
||||
if (!parent) return null
|
||||
if (typeof parent === 'string') {
|
||||
const parts = parent.split('/')
|
||||
return parts[parts.length - 1] || null
|
||||
}
|
||||
if (typeof parent === 'object' && parent.id) {
|
||||
return parent.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Navbar expand="lg" className="bb-nav">
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/" className="fw-semibold">
|
||||
{t('app.brand')}
|
||||
</Navbar.Brand>
|
||||
{isAdmin && (
|
||||
<Nav className="me-auto">
|
||||
<Nav.Link as={Link} to="/acp">
|
||||
{t('nav.acp')}
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
)}
|
||||
<Navbar.Toggle aria-controls="bb-nav" />
|
||||
<Navbar.Collapse id="bb-nav">
|
||||
<Nav className="ms-auto align-items-lg-center gap-2">
|
||||
{!token && (
|
||||
<>
|
||||
<Nav.Link as={Link} to="/login">
|
||||
{t('nav.login')}
|
||||
</Nav.Link>
|
||||
<Nav.Link as={Link} to="/register">
|
||||
{t('nav.register')}
|
||||
</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
{token && (
|
||||
<>
|
||||
<span className="bb-chip">{email}</span>
|
||||
<Nav.Link onClick={logout}>{t('nav.logout')}</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
<NavDropdown title={t('nav.language')} align="end">
|
||||
<NavDropdown.Item onClick={() => handleLanguageChange('en')}>
|
||||
English
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleLanguageChange('de')}>
|
||||
Deutsch
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
<NavDropdown title={t('nav.theme')} align="end">
|
||||
<NavDropdown.Item onClick={() => handleThemeChange('auto')} active={theme === 'auto'}>
|
||||
{t('nav.theme_auto')}
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleThemeChange('light')} active={theme === 'light'}>
|
||||
{t('nav.theme_light')}
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleThemeChange('dark')} active={theme === 'dark'}>
|
||||
{t('nav.theme_dark')}
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
)
|
||||
const buildForumChain = async (forum) => {
|
||||
const chain = []
|
||||
let cursor = forum
|
||||
|
||||
while (cursor) {
|
||||
chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` })
|
||||
const parentId = parseForumId(cursor.parent)
|
||||
if (!parentId) break
|
||||
cursor = await getForum(parentId)
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
const buildCrumbs = async () => {
|
||||
const base = [
|
||||
{ label: t('portal.portal'), to: '/' },
|
||||
{ label: t('portal.board_index'), to: '/forums' },
|
||||
]
|
||||
|
||||
if (location.pathname === '/') {
|
||||
setCrumbs([{ ...base[0], current: true }, { ...base[1] }])
|
||||
return
|
||||
}
|
||||
|
||||
if (location.pathname === '/forums') {
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
return
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/forum/')) {
|
||||
const forumId = location.pathname.split('/')[2]
|
||||
if (forumId) {
|
||||
const forum = await getForum(forumId)
|
||||
const chain = await buildForumChain(forum)
|
||||
if (!active) return
|
||||
setCrumbs([...base, ...chain.map((crumb, idx) => ({
|
||||
...crumb,
|
||||
current: idx === chain.length - 1,
|
||||
}))])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/thread/')) {
|
||||
const threadId = location.pathname.split('/')[2]
|
||||
if (threadId) {
|
||||
const thread = await getThread(threadId)
|
||||
const forumId = thread?.forum?.split('/').pop()
|
||||
if (forumId) {
|
||||
const forum = await getForum(forumId)
|
||||
const chain = await buildForumChain(forum)
|
||||
if (!active) return
|
||||
const chainWithCurrent = chain.map((crumb, index) => ({
|
||||
...crumb,
|
||||
current: index === chain.length - 1,
|
||||
}))
|
||||
setCrumbs([...base, ...chainWithCurrent])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }])
|
||||
}
|
||||
|
||||
buildCrumbs()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [location.pathname, t])
|
||||
|
||||
return (
|
||||
<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 />
|
||||
<span className="bb-portal-search-icon">
|
||||
<i className="bi bi-search" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bb-portal-bars">
|
||||
<div className="bb-portal-bar bb-portal-bar--top">
|
||||
<div className="bb-portal-bar-left">
|
||||
<span className="bb-portal-bar-title">
|
||||
<i className="bi bi-list" aria-hidden="true" /> {t('portal.quick_links')}
|
||||
</span>
|
||||
<div className="bb-portal-bar-links">
|
||||
<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
|
||||
className={`bb-portal-user-links${isAuthenticated ? '' : ' bb-portal-user-links--guest'}`}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<span>
|
||||
<i className="bi bi-bell-fill" aria-hidden="true" /> {t('portal.notifications')}
|
||||
</span>
|
||||
<span>
|
||||
<i className="bi bi-envelope-fill" aria-hidden="true" /> {t('portal.messages')}
|
||||
</span>
|
||||
{userMenu}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/register" className="bb-portal-user-link">
|
||||
<i className="bi bi-pencil-square" aria-hidden="true" /> {t('nav.register')}
|
||||
</Link>
|
||||
<Link to="/login" className="bb-portal-user-link">
|
||||
<i className="bi bi-power" aria-hidden="true" /> {t('nav.login')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-bar bb-portal-bar--bottom">
|
||||
<div className="bb-portal-breadcrumb">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
|
||||
{index > 0 && <span className="bb-portal-sep">›</span>}
|
||||
{crumb.current ? (
|
||||
<Link to={crumb.to} className="bb-portal-current bb-portal-link">
|
||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={crumb.to} className="bb-portal-link">
|
||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
|
||||
{crumb.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation()
|
||||
const { isAdmin } = useAuth()
|
||||
const [loadMs, setLoadMs] = useState(null)
|
||||
const [versionInfo, setVersionInfo] = useState(null)
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
|
||||
const { t } = useTranslation()
|
||||
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
|
||||
const [versionInfo, setVersionInfo] = useState(null)
|
||||
const [theme, setTheme] = useState('auto')
|
||||
const [resolvedTheme, setResolvedTheme] = useState('light')
|
||||
const [accentOverride, setAccentOverride] = useState(
|
||||
() => localStorage.getItem('speedbb_accent') || ''
|
||||
)
|
||||
const [settings, setSettings] = useState({
|
||||
forumName: '',
|
||||
defaultTheme: 'auto',
|
||||
accentDark: '',
|
||||
accentLight: '',
|
||||
logoDark: '',
|
||||
logoLight: '',
|
||||
showHeaderName: true,
|
||||
faviconIco: '',
|
||||
favicon16: '',
|
||||
favicon32: '',
|
||||
favicon48: '',
|
||||
favicon64: '',
|
||||
favicon128: '',
|
||||
favicon256: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const [entry] = performance.getEntriesByType('navigation')
|
||||
if (entry?.duration) {
|
||||
setLoadMs(Math.round(entry.duration))
|
||||
return
|
||||
}
|
||||
setLoadMs(Math.round(performance.now()))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
fetchVersion()
|
||||
.then((data) => setVersionInfo(data))
|
||||
.catch(() => setVersionInfo(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion()
|
||||
.then((data) => setVersionInfo(data))
|
||||
.catch(() => setVersionInfo(null))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const allSettings = await fetchSettings()
|
||||
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
|
||||
if (!active) return
|
||||
const next = {
|
||||
forumName: settingsMap.get('forum_name') || '',
|
||||
defaultTheme: settingsMap.get('default_theme') || 'auto',
|
||||
accentDark: settingsMap.get('accent_color_dark') || '',
|
||||
accentLight: settingsMap.get('accent_color_light') || '',
|
||||
logoDark: settingsMap.get('logo_dark') || '',
|
||||
logoLight: settingsMap.get('logo_light') || '',
|
||||
showHeaderName: settingsMap.get('show_header_name') !== 'false',
|
||||
faviconIco: settingsMap.get('favicon_ico') || '',
|
||||
favicon16: settingsMap.get('favicon_16') || '',
|
||||
favicon32: settingsMap.get('favicon_32') || '',
|
||||
favicon48: settingsMap.get('favicon_48') || '',
|
||||
favicon64: settingsMap.get('favicon_64') || '',
|
||||
favicon128: settingsMap.get('favicon_128') || '',
|
||||
favicon256: settingsMap.get('favicon_256') || '',
|
||||
}
|
||||
setSettings(next)
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSetting('accent_color')
|
||||
.then((setting) => {
|
||||
if (setting?.value) {
|
||||
document.documentElement.style.setProperty('--bb-accent', setting.value)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const stored = token ? localStorage.getItem('speedbb_theme') : null
|
||||
const nextTheme = stored || settings.defaultTheme || 'auto'
|
||||
setTheme(nextTheme)
|
||||
}, [token, settings.defaultTheme])
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
useEffect(() => {
|
||||
const handleSettingsUpdate = (event) => {
|
||||
const next = event.detail
|
||||
if (!next) return
|
||||
setSettings((prev) => ({ ...prev, ...next }))
|
||||
}
|
||||
|
||||
const applyTheme = (mode) => {
|
||||
if (mode === 'auto') {
|
||||
root.setAttribute('data-bs-theme', media.matches ? 'dark' : 'light')
|
||||
} else {
|
||||
root.setAttribute('data-bs-theme', mode)
|
||||
}
|
||||
}
|
||||
window.addEventListener('speedbb-settings-updated', handleSettingsUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
applyTheme(theme)
|
||||
useEffect(() => {
|
||||
if (accentOverride) {
|
||||
localStorage.setItem('speedbb_accent', accentOverride)
|
||||
} else {
|
||||
localStorage.removeItem('speedbb_accent')
|
||||
}
|
||||
}, [accentOverride])
|
||||
|
||||
const handleChange = () => {
|
||||
if (theme === 'auto') {
|
||||
applyTheme('auto')
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
media.addEventListener('change', handleChange)
|
||||
const applyTheme = (mode) => {
|
||||
if (mode === 'auto') {
|
||||
const next = media.matches ? 'dark' : 'light'
|
||||
root.setAttribute('data-bs-theme', next)
|
||||
setResolvedTheme(next)
|
||||
} else {
|
||||
root.setAttribute('data-bs-theme', mode)
|
||||
setResolvedTheme(mode)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [theme])
|
||||
applyTheme(theme)
|
||||
|
||||
return (
|
||||
<div className="bb-shell">
|
||||
<Navigation theme={theme} onThemeChange={setTheme} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/forum/:id" element={<ForumView />} />
|
||||
<Route path="/thread/:id" element={<ThreadView />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
||||
</Routes>
|
||||
<footer className="bb-footer">
|
||||
<div className="ms-3 d-flex align-items-center gap-3">
|
||||
<span>{t('footer.copy')}</span>
|
||||
{versionInfo?.version && (
|
||||
<span className="bb-version">
|
||||
<span className="bb-version-label">Version:</span>{' '}
|
||||
<span className="bb-version-value">{versionInfo.version}</span>{' '}
|
||||
<span className="bb-version-label">(build:</span>{' '}
|
||||
<span className="bb-version-value">{versionInfo.build}</span>
|
||||
<span className="bb-version-label">)</span>
|
||||
</span>
|
||||
)}
|
||||
{loadMs !== null && (
|
||||
<span className="bb-load-time">
|
||||
<span className="bb-load-label">Page load time</span>{' '}
|
||||
<span className="bb-load-value">{loadMs}ms</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
const handleChange = () => {
|
||||
if (theme === 'auto') {
|
||||
applyTheme('auto')
|
||||
}
|
||||
}
|
||||
|
||||
media.addEventListener('change', handleChange)
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const accent =
|
||||
accentOverride ||
|
||||
(resolvedTheme === 'dark' ? settings.accentDark : settings.accentLight) ||
|
||||
settings.accentDark ||
|
||||
settings.accentLight
|
||||
if (accent) {
|
||||
document.documentElement.style.setProperty('--bb-accent', accent)
|
||||
}
|
||||
}, [accentOverride, resolvedTheme, settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.forumName) {
|
||||
document.title = settings.forumName
|
||||
}
|
||||
}, [settings.forumName])
|
||||
|
||||
useEffect(() => {
|
||||
const upsertIcon = (id, rel, href, sizes, type) => {
|
||||
if (!href) {
|
||||
const existing = document.getElementById(id)
|
||||
if (existing) {
|
||||
existing.remove()
|
||||
}
|
||||
return
|
||||
}
|
||||
let link = document.getElementById(id)
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.id = id
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.setAttribute('rel', rel)
|
||||
link.setAttribute('href', href)
|
||||
if (sizes) {
|
||||
link.setAttribute('sizes', sizes)
|
||||
} else {
|
||||
link.removeAttribute('sizes')
|
||||
}
|
||||
if (type) {
|
||||
link.setAttribute('type', type)
|
||||
} else {
|
||||
link.removeAttribute('type')
|
||||
}
|
||||
}
|
||||
|
||||
upsertIcon('favicon-ico', 'icon', settings.faviconIco, null, 'image/x-icon')
|
||||
upsertIcon('favicon-16', 'icon', settings.favicon16, '16x16', 'image/png')
|
||||
upsertIcon('favicon-32', 'icon', settings.favicon32, '32x32', 'image/png')
|
||||
upsertIcon('favicon-48', 'icon', settings.favicon48, '48x48', 'image/png')
|
||||
upsertIcon('favicon-64', 'icon', settings.favicon64, '64x64', 'image/png')
|
||||
upsertIcon('favicon-128', 'icon', settings.favicon128, '128x128', 'image/png')
|
||||
upsertIcon('favicon-256', 'icon', settings.favicon256, '256x256', 'image/png')
|
||||
}, [
|
||||
settings.faviconIco,
|
||||
settings.favicon16,
|
||||
settings.favicon32,
|
||||
settings.favicon48,
|
||||
settings.favicon64,
|
||||
settings.favicon128,
|
||||
settings.favicon256,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="bb-shell" id="top">
|
||||
<PortalHeader
|
||||
isAuthenticated={!!token}
|
||||
forumName={settings.forumName}
|
||||
logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight}
|
||||
showHeaderName={settings.showHeaderName}
|
||||
userMenu={
|
||||
token ? (
|
||||
<NavDropdown
|
||||
title={
|
||||
<span className="bb-user-menu">
|
||||
<span className="bb-user-menu__name">{email}</span>
|
||||
<i className="bi bi-caret-down-fill" aria-hidden="true" />
|
||||
</span>
|
||||
}
|
||||
align="end"
|
||||
className="bb-user-menu__dropdown"
|
||||
>
|
||||
<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={`/profile/${userId ?? ''}`}>
|
||||
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={logout}>
|
||||
<i className="bi bi-power" aria-hidden="true" /> {t('portal.user_logout')}
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
) : null
|
||||
}
|
||||
canAccessAcp={isAdmin}
|
||||
canAccessMcp={isModerator}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/forums" element={<BoardIndex />} />
|
||||
<Route path="/forum/:id" element={<ForumView />} />
|
||||
<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"
|
||||
element={
|
||||
<Ucp
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
accentOverride={accentOverride}
|
||||
setAccentOverride={setAccentOverride}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<footer className="bb-footer">
|
||||
<div className="ms-3 d-flex align-items-center gap-3">
|
||||
<span>{t('footer.copy')}</span>
|
||||
{versionInfo?.version && (
|
||||
<span className="bb-version">
|
||||
<span className="bb-version-label">Version:</span>{' '}
|
||||
<span className="bb-version-value">{versionInfo.version}</span>{' '}
|
||||
<span className="bb-version-label">(build:</span>{' '}
|
||||
<span className="bb-version-value">{versionInfo.build}</span>
|
||||
<span className="bb-version-label">)</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,168 +1,347 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function parseResponse(response) {
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const message = data?.message || data?.['hydra:description'] || response.statusText
|
||||
throw new Error(message)
|
||||
}
|
||||
return data
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const message = data?.message || data?.['hydra:description'] || response.statusText
|
||||
throw new Error(message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const token = localStorage.getItem('speedbb_token')
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
}
|
||||
if (!(options.body instanceof FormData)) {
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
const token = localStorage.getItem('speedbb_token')
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('speedbb_token')
|
||||
localStorage.removeItem('speedbb_email')
|
||||
localStorage.removeItem('speedbb_user_id')
|
||||
localStorage.removeItem('speedbb_roles')
|
||||
window.dispatchEvent(new Event('speedbb-unauthorized'))
|
||||
}
|
||||
return parseResponse(response)
|
||||
if (!(options.body instanceof FormData)) {
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('speedbb_token')
|
||||
localStorage.removeItem('speedbb_email')
|
||||
localStorage.removeItem('speedbb_user_id')
|
||||
localStorage.removeItem('speedbb_roles')
|
||||
window.dispatchEvent(new Event('speedbb-unauthorized'))
|
||||
}
|
||||
return parseResponse(response)
|
||||
}
|
||||
|
||||
export async function getCollection(path) {
|
||||
const data = await apiFetch(path)
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
return data?.['hydra:member'] || []
|
||||
const data = await apiFetch(path)
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
return data?.['hydra:member'] || []
|
||||
}
|
||||
|
||||
export async function login(email, password) {
|
||||
return apiFetch('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
export async function login(login, password) {
|
||||
return apiFetch('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ login, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function registerUser({ email, username, plainPassword }) {
|
||||
return apiFetch('/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, plainPassword }),
|
||||
})
|
||||
return apiFetch('/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, plainPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRootForums() {
|
||||
return getCollection('/forums?parent[exists]=false')
|
||||
return getCollection('/forums?parent[exists]=false')
|
||||
}
|
||||
|
||||
export async function listAllForums() {
|
||||
return getCollection('/forums?pagination=false')
|
||||
return getCollection('/forums?pagination=false')
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
return apiFetch('/user/me')
|
||||
}
|
||||
|
||||
export async function 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 listUserThanksGiven(id) {
|
||||
return apiFetch(`/user/${id}/thanks/given`)
|
||||
}
|
||||
|
||||
export async function listUserThanksReceived(id) {
|
||||
return apiFetch(`/user/${id}/thanks/received`)
|
||||
}
|
||||
|
||||
export async function fetchVersion() {
|
||||
return apiFetch('/version')
|
||||
return apiFetch('/version')
|
||||
}
|
||||
|
||||
export async function fetchStats() {
|
||||
return apiFetch('/stats')
|
||||
}
|
||||
|
||||
export async function fetchPortalSummary() {
|
||||
return apiFetch('/portal/summary')
|
||||
}
|
||||
|
||||
export async function fetchSetting(key) {
|
||||
const data = await getCollection(`/settings?key=${encodeURIComponent(key)}&pagination=false`)
|
||||
return data[0] || null
|
||||
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
||||
const cacheBust = Date.now()
|
||||
const data = await apiFetch(
|
||||
`/settings?key=${encodeURIComponent(key)}&pagination=false&_=${cacheBust}`,
|
||||
{ cache: 'no-store' }
|
||||
)
|
||||
if (Array.isArray(data)) {
|
||||
return data[0] || null
|
||||
}
|
||||
return data?.['hydra:member']?.[0] || null
|
||||
}
|
||||
|
||||
export async function fetchSettings() {
|
||||
const cacheBust = Date.now()
|
||||
const data = await apiFetch(`/settings?pagination=false&_=${cacheBust}`, { cache: 'no-store' })
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
return data?.['hydra:member'] || []
|
||||
}
|
||||
|
||||
export async function saveSetting(key, value) {
|
||||
return apiFetch('/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveSettings(settings) {
|
||||
return apiFetch('/settings/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ settings }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadLogo(file) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return apiFetch('/uploads/logo', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadFavicon(file) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return apiFetch('/uploads/favicon', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchUserSetting(key) {
|
||||
const data = await getCollection(`/user-settings?key=${encodeURIComponent(key)}&pagination=false`)
|
||||
return data[0] || null
|
||||
}
|
||||
|
||||
export async function saveUserSetting(key, value) {
|
||||
return apiFetch('/user-settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listForumsByParent(parentId) {
|
||||
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
||||
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
||||
}
|
||||
|
||||
export async function getForum(id) {
|
||||
return apiFetch(`/forums/${id}`)
|
||||
return apiFetch(`/forums/${id}`)
|
||||
}
|
||||
|
||||
export async function createForum({ name, description, type, parentId }) {
|
||||
return apiFetch('/forums', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
parent: parentId ? `/api/forums/${parentId}` : null,
|
||||
}),
|
||||
})
|
||||
return apiFetch('/forums', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
parent: parentId ? `/api/forums/${parentId}` : null,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateForum(id, { name, description, type, parentId }) {
|
||||
return apiFetch(`/forums/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
parent: parentId ? `/api/forums/${parentId}` : null,
|
||||
}),
|
||||
})
|
||||
return apiFetch(`/forums/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
parent: parentId ? `/api/forums/${parentId}` : null,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteForum(id) {
|
||||
return apiFetch(`/forums/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return apiFetch(`/forums/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function reorderForums(parentId, orderedIds) {
|
||||
return apiFetch('/forums/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
parentId,
|
||||
orderedIds,
|
||||
}),
|
||||
})
|
||||
return apiFetch('/forums/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
parentId,
|
||||
orderedIds,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listThreadsByForum(forumId) {
|
||||
return getCollection(`/threads?forum=/api/forums/${forumId}`)
|
||||
return getCollection(`/threads?forum=/api/forums/${forumId}`)
|
||||
}
|
||||
|
||||
export async function listThreads() {
|
||||
return getCollection('/threads')
|
||||
}
|
||||
|
||||
export async function getThread(id) {
|
||||
return apiFetch(`/threads/${id}`)
|
||||
return apiFetch(`/threads/${id}`)
|
||||
}
|
||||
|
||||
export async function listPostsByThread(threadId) {
|
||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||
}
|
||||
|
||||
export async function listUsers() {
|
||||
return getCollection('/users')
|
||||
return getCollection('/users')
|
||||
}
|
||||
|
||||
export async function listRanks() {
|
||||
return getCollection('/ranks')
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
return getCollection('/roles')
|
||||
}
|
||||
|
||||
export async function createRole(payload) {
|
||||
return apiFetch('/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateRole(roleId, payload) {
|
||||
return apiFetch(`/roles/${roleId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteRole(roleId) {
|
||||
return apiFetch(`/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
forum: `/api/forums/${forumId}`,
|
||||
}),
|
||||
})
|
||||
return apiFetch('/threads', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
forum: `/api/forums/${forumId}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPost({ body, threadId }) {
|
||||
return apiFetch('/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body,
|
||||
thread: `/api/threads/${threadId}`,
|
||||
}),
|
||||
})
|
||||
return apiFetch('/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body,
|
||||
thread: `/api/threads/${threadId}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
107
resources/js/components/PortalTopicRow.jsx
Normal file
107
resources/js/components/PortalTopicRow.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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 authorLinkColor = thread.user_rank_color || thread.user_group_color
|
||||
const authorLinkStyle = authorLinkColor
|
||||
? { '--bb-user-link-color': authorLinkColor }
|
||||
: undefined
|
||||
const lastAuthorLinkColor = thread.last_post_user_rank_color || thread.last_post_user_group_color
|
||||
const lastAuthorLinkStyle = lastAuthorLinkColor
|
||||
? { '--bb-user-link-color': lastAuthorLinkColor }
|
||||
: undefined
|
||||
|
||||
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"
|
||||
style={authorLinkStyle}
|
||||
>
|
||||
{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"
|
||||
style={lastAuthorLinkStyle}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -4,90 +4,92 @@ import { login as apiLogin } from '../api/client'
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [token, setToken] = useState(() => localStorage.getItem('speedbb_token'))
|
||||
const [email, setEmail] = useState(() => localStorage.getItem('speedbb_email'))
|
||||
const [userId, setUserId] = useState(() => {
|
||||
const stored = localStorage.getItem('speedbb_user_id')
|
||||
if (stored) return stored
|
||||
return null
|
||||
})
|
||||
const [roles, setRoles] = useState(() => {
|
||||
const stored = localStorage.getItem('speedbb_roles')
|
||||
if (stored) return JSON.parse(stored)
|
||||
return []
|
||||
})
|
||||
|
||||
const effectiveRoles = token ? roles : []
|
||||
const effectiveUserId = token ? userId : null
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
token,
|
||||
email,
|
||||
userId: effectiveUserId,
|
||||
roles: effectiveRoles,
|
||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||
async login(emailInput, password) {
|
||||
const data = await apiLogin(emailInput, password)
|
||||
localStorage.setItem('speedbb_token', data.token)
|
||||
localStorage.setItem('speedbb_email', data.email || emailInput)
|
||||
if (data.user_id) {
|
||||
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
||||
setUserId(String(data.user_id))
|
||||
}
|
||||
if (Array.isArray(data.roles)) {
|
||||
localStorage.setItem('speedbb_roles', JSON.stringify(data.roles))
|
||||
setRoles(data.roles)
|
||||
} else {
|
||||
localStorage.removeItem('speedbb_roles')
|
||||
setRoles([])
|
||||
}
|
||||
setToken(data.token)
|
||||
setEmail(data.email || emailInput)
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem('speedbb_token')
|
||||
localStorage.removeItem('speedbb_email')
|
||||
localStorage.removeItem('speedbb_user_id')
|
||||
localStorage.removeItem('speedbb_roles')
|
||||
setToken(null)
|
||||
setEmail(null)
|
||||
setUserId(null)
|
||||
setRoles([])
|
||||
},
|
||||
}),
|
||||
[token, email, effectiveUserId, effectiveRoles]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => {
|
||||
setToken(null)
|
||||
setEmail(null)
|
||||
setUserId(null)
|
||||
setRoles([])
|
||||
}
|
||||
|
||||
window.addEventListener('speedbb-unauthorized', handleUnauthorized)
|
||||
return () => window.removeEventListener('speedbb-unauthorized', handleUnauthorized)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('speedBB auth', {
|
||||
email,
|
||||
userId: effectiveUserId,
|
||||
roles: effectiveRoles,
|
||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||
hasToken: Boolean(token),
|
||||
const [token, setToken] = useState(() => localStorage.getItem('speedbb_token'))
|
||||
const [email, setEmail] = useState(() => localStorage.getItem('speedbb_email'))
|
||||
const [userId, setUserId] = useState(() => {
|
||||
const stored = localStorage.getItem('speedbb_user_id')
|
||||
if (stored) return stored
|
||||
return null
|
||||
})
|
||||
const [roles, setRoles] = useState(() => {
|
||||
const stored = localStorage.getItem('speedbb_roles')
|
||||
if (stored) return JSON.parse(stored)
|
||||
return []
|
||||
})
|
||||
}, [email, effectiveUserId, effectiveRoles, token])
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
const effectiveRoles = token ? roles : []
|
||||
const effectiveUserId = token ? userId : null
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
token,
|
||||
email,
|
||||
userId: effectiveUserId,
|
||||
roles: effectiveRoles,
|
||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||
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 || loginInput)
|
||||
if (data.user_id) {
|
||||
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
||||
setUserId(String(data.user_id))
|
||||
}
|
||||
if (Array.isArray(data.roles)) {
|
||||
localStorage.setItem('speedbb_roles', JSON.stringify(data.roles))
|
||||
setRoles(data.roles)
|
||||
} else {
|
||||
localStorage.removeItem('speedbb_roles')
|
||||
setRoles([])
|
||||
}
|
||||
setToken(data.token)
|
||||
setEmail(data.email || loginInput)
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem('speedbb_token')
|
||||
localStorage.removeItem('speedbb_email')
|
||||
localStorage.removeItem('speedbb_user_id')
|
||||
localStorage.removeItem('speedbb_roles')
|
||||
setToken(null)
|
||||
setEmail(null)
|
||||
setUserId(null)
|
||||
setRoles([])
|
||||
},
|
||||
}),
|
||||
[token, email, effectiveUserId, effectiveRoles]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => {
|
||||
setToken(null)
|
||||
setEmail(null)
|
||||
setUserId(null)
|
||||
setRoles([])
|
||||
}
|
||||
|
||||
window.addEventListener('speedbb-unauthorized', handleUnauthorized)
|
||||
return () => window.removeEventListener('speedbb-unauthorized', handleUnauthorized)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('speedBB auth', {
|
||||
email,
|
||||
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])
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return ctx
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -5,21 +5,21 @@ import { initReactI18next } from 'react-i18next'
|
||||
const storedLanguage = localStorage.getItem('speedbb_lang') || 'en'
|
||||
|
||||
i18n
|
||||
.use(HttpBackend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: storedLanguage,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de'],
|
||||
backend: {
|
||||
loadPath: '/api/i18n/{{lng}}',
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
.use(HttpBackend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: storedLanguage,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de'],
|
||||
backend: {
|
||||
loadPath: '/api/i18n/{{lng}}',
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import './i18n'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
241
resources/js/pages/BoardIndex.jsx
Normal file
241
resources/js/pages/BoardIndex.jsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchUserSetting, listAllForums, saveUserSetting } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function BoardIndex() {
|
||||
const [forums, setForums] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [collapsed, setCollapsed] = useState({})
|
||||
const { t } = useTranslation()
|
||||
const { token } = useAuth()
|
||||
const collapsedKey = 'board_index.collapsed_categories'
|
||||
const storageKey = `speedbb_user_setting_${collapsedKey}`
|
||||
const saveTimer = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
let active = true
|
||||
|
||||
const cached = localStorage.getItem(storageKey)
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached)
|
||||
if (Array.isArray(parsed)) {
|
||||
const next = {}
|
||||
parsed.forEach((id) => {
|
||||
next[String(id)] = true
|
||||
})
|
||||
setCollapsed(next)
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUserSetting(collapsedKey)
|
||||
.then((setting) => {
|
||||
if (!active) return
|
||||
const next = {}
|
||||
if (Array.isArray(setting?.value)) {
|
||||
setting.value.forEach((id) => {
|
||||
next[String(id)] = true
|
||||
})
|
||||
}
|
||||
setCollapsed(next)
|
||||
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
return forum.parent.split('/').pop()
|
||||
}
|
||||
return forum.parent.id ?? null
|
||||
}
|
||||
|
||||
const forumTree = useMemo(() => {
|
||||
const map = new Map()
|
||||
const roots = []
|
||||
|
||||
forums.forEach((forum) => {
|
||||
map.set(String(forum.id), { ...forum, children: [] })
|
||||
})
|
||||
|
||||
forums.forEach((forum) => {
|
||||
const parentId = getParentId(forum)
|
||||
const node = map.get(String(forum.id))
|
||||
if (parentId && map.has(String(parentId))) {
|
||||
map.get(String(parentId)).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
const sortNodes = (nodes) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
nodes.forEach((node) => sortNodes(node.children))
|
||||
}
|
||||
|
||||
sortNodes(roots)
|
||||
|
||||
return roots
|
||||
}, [forums])
|
||||
|
||||
const renderRows = (nodes) =>
|
||||
nodes.map((node) => (
|
||||
<div className="bb-board-row" key={node.id}>
|
||||
<div className="bb-board-cell bb-board-cell--title">
|
||||
<div className="bb-board-title">
|
||||
<span className="bb-board-icon" aria-hidden="true">
|
||||
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
||||
{node.name}
|
||||
</Link>
|
||||
<div className="bb-board-desc">{node.description || ''}</div>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="bb-board-subforums">
|
||||
{t('forum.children')}:{' '}
|
||||
{node.children.map((child, index) => (
|
||||
<span key={child.id}>
|
||||
<Link to={`/forum/${child.id}`} className="bb-board-subforum-link">
|
||||
{child.name}
|
||||
</Link>
|
||||
{index < node.children.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
style={
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{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 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 && (
|
||||
<p className="bb-muted">{t('home.empty')}</p>
|
||||
)}
|
||||
{forumTree.length > 0 && (
|
||||
<div className="bb-board-index">
|
||||
{forumTree.map((category) => (
|
||||
<section className="bb-board-section" key={category.id}>
|
||||
<header className="bb-board-section__header">
|
||||
<span className="bb-board-section__title">{category.name}</span>
|
||||
<div className="bb-board-section__controls">
|
||||
<div className="bb-board-section__cols">
|
||||
<span>{t('portal.topic')}</span>
|
||||
<span>{t('thread.views')}</span>
|
||||
<span>{t('thread.last_post')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bb-board-toggle"
|
||||
onClick={() =>
|
||||
setCollapsed((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
[category.id]: !prev[category.id],
|
||||
}
|
||||
const collapsedIds = Object.keys(next).filter((key) => next[key])
|
||||
localStorage.setItem(storageKey, JSON.stringify(collapsedIds))
|
||||
if (token) {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current)
|
||||
}
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveUserSetting(collapsedKey, collapsedIds).catch(() => {})
|
||||
}, 400)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
aria-label={
|
||||
collapsed[category.id]
|
||||
? t('forum.expand_category')
|
||||
: t('forum.collapse_category')
|
||||
}
|
||||
>
|
||||
<i
|
||||
className={`bi ${
|
||||
collapsed[category.id] ? 'bi-plus-square' : 'bi-dash-square'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{!collapsed[category.id] && (
|
||||
<div className="bb-board-section__body">
|
||||
{category.children?.length > 0 ? (
|
||||
renderRows(category.children)
|
||||
) : (
|
||||
<div className="bb-board-empty">{t('forum.empty_children')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,180 +1,258 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
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'
|
||||
|
||||
export default function ForumView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const [forum, setForum] = useState(null)
|
||||
const [children, setChildren] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const [forum, setForum] = useState(null)
|
||||
const [children, setChildren] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const renderChildRows = (nodes) =>
|
||||
nodes.map((node) => (
|
||||
<div className="bb-board-row" key={node.id}>
|
||||
<div className="bb-board-cell bb-board-cell--title">
|
||||
<div className="bb-board-title">
|
||||
<span className="bb-board-icon" aria-hidden="true">
|
||||
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
||||
{node.name}
|
||||
</Link>
|
||||
<div className="bb-board-desc">{node.description || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
style={
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{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>
|
||||
))
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const forumData = await getForum(id)
|
||||
if (!active) return
|
||||
setForum(forumData)
|
||||
const childData = await listForumsByParent(id)
|
||||
if (!active) return
|
||||
setChildren(childData)
|
||||
if (forumData.type === 'forum') {
|
||||
const threadData = await listThreadsByForum(id)
|
||||
if (!active) return
|
||||
setThreads(threadData)
|
||||
} else {
|
||||
setThreads([])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const forumData = await getForum(id)
|
||||
if (!active) return
|
||||
setForum(forumData)
|
||||
const childData = await listForumsByParent(id)
|
||||
if (!active) return
|
||||
setChildren(childData)
|
||||
if (forumData.type === 'forum') {
|
||||
const threadData = await listThreadsByForum(id)
|
||||
if (!active) return
|
||||
setThreads(threadData)
|
||||
} else {
|
||||
setThreads([])
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err.message)
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await createThread({ title, body, forumId: id })
|
||||
setTitle('')
|
||||
setBody('')
|
||||
const updated = await listThreadsByForum(id)
|
||||
setThreads(updated)
|
||||
setShowModal(false)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err.message)
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await createThread({ title, body, forumId: id })
|
||||
setTitle('')
|
||||
setBody('')
|
||||
const updated = await listThreadsByForum(id)
|
||||
setThreads(updated)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{forum && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">
|
||||
{forum.type === 'forum' ? t('forum.type_forum') : t('forum.type_category')}
|
||||
</p>
|
||||
<h2 className="mt-3">{forum.name}</h2>
|
||||
<p className="bb-muted mb-0">
|
||||
{forum.description || t('forum.no_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">{t('forum.children')}</h4>
|
||||
{children.length === 0 && (
|
||||
<p className="bb-muted">{t('forum.empty_children')}</p>
|
||||
)}
|
||||
{children.map((child) => (
|
||||
<Card className="bb-card mb-3" key={child.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{child.name}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{child.description || t('forum.no_description')}
|
||||
</Card.Text>
|
||||
<Link to={`/forum/${child.id}`} className="stretched-link">
|
||||
{t('forum.open')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{forum.type === 'forum' && (
|
||||
return (
|
||||
<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 && (
|
||||
<>
|
||||
<h4 className="bb-section-title mb-3 mt-4">{t('forum.threads')}</h4>
|
||||
{threads.length === 0 && (
|
||||
<p className="bb-muted">{t('forum.empty_threads')}</p>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<Card className="bb-card mb-3" key={thread.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{thread.title}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{thread.body.length > 160
|
||||
? `${thread.body.slice(0, 160)}...`
|
||||
: thread.body}
|
||||
</Card.Text>
|
||||
<Link to={`/thread/${thread.id}`} className="stretched-link">
|
||||
{t('thread.view')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
<Row className="g-4">
|
||||
<Col lg={12}>
|
||||
{forum.type !== 'forum' && (
|
||||
<div className="bb-board-index">
|
||||
<section className="bb-board-section">
|
||||
<header className="bb-board-section__header">
|
||||
<span className="bb-board-section__title">{forum.name}</span>
|
||||
<div className="bb-board-section__cols">
|
||||
<span>{t('portal.topic')}</span>
|
||||
<span>{t('thread.views')}</span>
|
||||
<span>{t('thread.last_post')}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="bb-board-section__body">
|
||||
{children.length > 0 ? (
|
||||
renderChildRows(children)
|
||||
) : (
|
||||
<div className="bb-board-empty">{t('forum.empty_children')}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{forum.type === 'forum' && (
|
||||
<>
|
||||
<div className="bb-topic-toolbar mt-4 mb-2">
|
||||
<div className="bb-topic-toolbar__left">
|
||||
<Button
|
||||
variant="dark"
|
||||
className="bb-topic-action bb-accent-button"
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={!token || saving}
|
||||
>
|
||||
<i className="bi bi-pencil me-2" aria-hidden="true" />
|
||||
{t('forum.start_thread')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bb-topic-toolbar__right">
|
||||
<span className="bb-topic-count">
|
||||
{threads.length} {t('forum.threads').toLowerCase()}
|
||||
</span>
|
||||
<div className="bb-topic-pagination">
|
||||
<Button size="sm" variant="outline-secondary" disabled>
|
||||
‹
|
||||
</Button>
|
||||
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
|
||||
1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline-secondary" disabled>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||
<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) => (
|
||||
<PortalTopicRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
forumName={forum?.name || t('portal.unknown_forum')}
|
||||
forumId={forum?.id}
|
||||
showForum={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">{t('forum.start_thread')}</h4>
|
||||
<div className="bb-form">
|
||||
{forum.type !== 'forum' && (
|
||||
<p className="bb-muted mb-3">{t('forum.only_forums')}</p>
|
||||
)}
|
||||
{forum.type === 'forum' && !token && (
|
||||
<p className="bb-muted mb-3">{t('forum.login_hint')}</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.title')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={t('form.thread_title_placeholder')}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.body')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder={t('form.thread_body_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
>
|
||||
{saving ? t('form.posting') : t('form.create_thread')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
)}
|
||||
{forum?.type === 'forum' && (
|
||||
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.title')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={t('form.thread_title_placeholder')}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.body')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={6}
|
||||
placeholder={t('form.thread_body_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<div className="d-flex gap-2 justify-content-between">
|
||||
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
||||
{saving ? t('form.posting') : t('form.create_thread')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,103 +1,262 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listAllForums } 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 [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
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(() => setLoading(false))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoadingForums(true)
|
||||
setLoadingThreads(true)
|
||||
setLoadingStats(true)
|
||||
setError('')
|
||||
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
return forum.parent.split('/').pop()
|
||||
}
|
||||
return forum.parent.id ?? null
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
const forumTree = useMemo(() => {
|
||||
const map = new Map()
|
||||
const roots = []
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
forums.forEach((forum) => {
|
||||
map.set(String(forum.id), { ...forum, children: [] })
|
||||
})
|
||||
|
||||
forums.forEach((forum) => {
|
||||
const parentId = getParentId(forum)
|
||||
const node = map.get(String(forum.id))
|
||||
if (parentId && map.has(String(parentId))) {
|
||||
map.get(String(parentId)).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
const sortNodes = (nodes) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
nodes.forEach((node) => sortNodes(node.children))
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
return forum.parent.split('/').pop()
|
||||
}
|
||||
return forum.parent.id ?? null
|
||||
}
|
||||
|
||||
sortNodes(roots)
|
||||
const forumTree = useMemo(() => {
|
||||
const map = new Map()
|
||||
const roots = []
|
||||
|
||||
return roots
|
||||
}, [forums])
|
||||
forums.forEach((forum) => {
|
||||
map.set(String(forum.id), { ...forum, children: [] })
|
||||
})
|
||||
|
||||
const renderTree = (nodes, depth = 0) =>
|
||||
nodes.map((node) => (
|
||||
<div key={node.id}>
|
||||
<div
|
||||
className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
|
||||
style={{ marginLeft: depth * 16 }}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
|
||||
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
|
||||
</span>
|
||||
<div>
|
||||
<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>
|
||||
forums.forEach((forum) => {
|
||||
const parentId = getParentId(forum)
|
||||
const node = map.get(String(forum.id))
|
||||
if (parentId && map.has(String(parentId))) {
|
||||
map.get(String(parentId)).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
const sortNodes = (nodes) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
nodes.forEach((node) => sortNodes(node.children))
|
||||
}
|
||||
|
||||
sortNodes(roots)
|
||||
|
||||
return roots
|
||||
}, [forums])
|
||||
|
||||
const forumMap = useMemo(() => {
|
||||
const map = new Map()
|
||||
forums.forEach((forum) => {
|
||||
map.set(String(forum.id), forum)
|
||||
})
|
||||
return map
|
||||
}, [forums])
|
||||
|
||||
const recentThreads = useMemo(() => {
|
||||
return [...threads]
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
.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('/')
|
||||
const id = parts[parts.length - 1]
|
||||
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
|
||||
}
|
||||
|
||||
const resolveForumId = (thread) => {
|
||||
if (!thread?.forum) return null
|
||||
const parts = thread.forum.split('/')
|
||||
return parts[parts.length - 1] || null
|
||||
}
|
||||
|
||||
const renderTree = (nodes, depth = 0) =>
|
||||
nodes.map((node) => (
|
||||
<div key={node.id}>
|
||||
<div
|
||||
className="bb-forum-row border rounded p-3 mb-2 d-flex align-items-center justify-content-between"
|
||||
style={{ marginLeft: depth * 16 }}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
|
||||
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
|
||||
{node.name}
|
||||
</Link>
|
||||
<div className="bb-muted">{node.description || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
))
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">{t('app.brand')}</p>
|
||||
<h1 className="mt-3">{t('home.hero_title')}</h1>
|
||||
<p className="bb-muted mb-0">
|
||||
{t('home.hero_body')}
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<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">
|
||||
<div className="bb-portal-card-title">{t('portal.menu')}</div>
|
||||
<ul className="bb-portal-list">
|
||||
<li>{t('portal.menu_news')}</li>
|
||||
<li>{t('portal.menu_gallery')}</li>
|
||||
<li>{t('portal.menu_calendar')}</li>
|
||||
<li>{t('portal.menu_rules')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bb-portal-card">
|
||||
<div className="bb-portal-card-title">{t('portal.stats')}</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_threads')}</span>
|
||||
<strong>{loadingStats ? '—' : stats.threads}</strong>
|
||||
</div>
|
||||
<div className="bb-portal-stat">
|
||||
<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>
|
||||
|
||||
<h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
|
||||
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{!loading && forumTree.length === 0 && (
|
||||
<p className="bb-muted">{t('home.empty')}</p>
|
||||
)}
|
||||
{forumTree.length > 0 && <div className="mt-2">{renderTree(forumTree)}</div>}
|
||||
</Container>
|
||||
)
|
||||
<main className="bb-portal-column bb-portal-column--center">
|
||||
<div className="bb-portal-card">
|
||||
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
|
||||
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
|
||||
{!loadingThreads && recentThreads.length === 0 && (
|
||||
<p className="bb-muted">{t('portal.empty_posts')}</p>
|
||||
)}
|
||||
{!loadingThreads && recentThreads.length > 0 && (
|
||||
<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>
|
||||
{recentThreads.map((thread) => (
|
||||
<PortalTopicRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
forumName={resolveForumName(thread)}
|
||||
forumId={resolveForumId(thread)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="bb-portal-column bb-portal-column--right">
|
||||
<div className="bb-portal-card">
|
||||
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
||||
<div className="bb-portal-user-card">
|
||||
<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"
|
||||
style={
|
||||
profile?.rank?.color || profile?.group_color
|
||||
? { '--bb-user-link-color': profile.rank?.color || profile.group_color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{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>
|
||||
<li>{t('portal.user_unread')}</li>
|
||||
<li>{t('portal.user_control_panel')}</li>
|
||||
<li>{t('portal.user_logout')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bb-portal-card bb-portal-card--ad">
|
||||
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
|
||||
<div className="bb-portal-ad-box">example.com</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{error && <p className="text-danger mt-3">{error}</p>}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,60 +5,61 @@ import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [loginValue, setLoginValue] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(loginValue, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container 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>
|
||||
<Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
return (
|
||||
<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>
|
||||
<Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('auth.login_identifier')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={loginValue}
|
||||
onChange={(event) => setLoginValue(event.target.value)}
|
||||
placeholder={t('auth.login_placeholder')}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
178
resources/js/pages/Profile.jsx
Normal file
178
resources/js/pages/Profile.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } 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)
|
||||
const [thanksGiven, setThanksGiven] = useState([])
|
||||
const [thanksReceived, setThanksReceived] = useState([])
|
||||
const [loadingThanks, setLoadingThanks] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
|
||||
.then(([profileData, givenData, receivedData]) => {
|
||||
if (!active) return
|
||||
setProfile(profileData)
|
||||
setThanksGiven(Array.isArray(givenData) ? givenData : [])
|
||||
setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return
|
||||
setError(err.message)
|
||||
setThanksGiven([])
|
||||
setThanksReceived([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
setLoadingThanks(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [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 (
|
||||
<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>
|
||||
)}
|
||||
{profile && (
|
||||
<div className="bb-profile-thanks mt-4">
|
||||
<div className="bb-profile-section">
|
||||
<div className="bb-portal-card-title">{t('profile.thanks_given')}</div>
|
||||
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
|
||||
{!loadingThanks && thanksGiven.length === 0 && (
|
||||
<p className="bb-muted">{t('profile.thanks_empty')}</p>
|
||||
)}
|
||||
{!loadingThanks && thanksGiven.length > 0 && (
|
||||
<ul className="bb-profile-thanks-list">
|
||||
{thanksGiven.map((item) => (
|
||||
<li key={item.id} className="bb-profile-thanks-item">
|
||||
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
|
||||
{item.thread_title || t('thread.label')}
|
||||
</Link>
|
||||
{item.post_author_id ? (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_for')}{' '}
|
||||
<Link
|
||||
to={`/profile/${item.post_author_id}`}
|
||||
style={
|
||||
item.post_author_rank_color || item.post_author_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
item.post_author_rank_color || item.post_author_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.post_author_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_for')} {item.post_author_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
<span className="bb-profile-thanks-date">
|
||||
{formatDateTime(item.thanked_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-profile-section">
|
||||
<div className="bb-portal-card-title">{t('profile.thanks_received')}</div>
|
||||
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
|
||||
{!loadingThanks && thanksReceived.length === 0 && (
|
||||
<p className="bb-muted">{t('profile.thanks_empty')}</p>
|
||||
)}
|
||||
{!loadingThanks && thanksReceived.length > 0 && (
|
||||
<ul className="bb-profile-thanks-list">
|
||||
{thanksReceived.map((item) => (
|
||||
<li key={item.id} className="bb-profile-thanks-item">
|
||||
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
|
||||
{item.thread_title || t('thread.label')}
|
||||
</Link>
|
||||
{item.thanker_id ? (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_by')}{' '}
|
||||
<Link
|
||||
to={`/profile/${item.thanker_id}`}
|
||||
style={
|
||||
item.thanker_rank_color || item.thanker_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
item.thanker_rank_color || item.thanker_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.thanker_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_by')} {item.thanker_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
<span className="bb-profile-thanks-date">
|
||||
{formatDateTime(item.thanked_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -5,76 +5,76 @@ import { registerUser } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [plainPassword, setPlainPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [plainPassword, setPlainPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setNotice('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await registerUser({ email, username, plainPassword })
|
||||
setNotice(t('auth.verify_notice'))
|
||||
setEmail('')
|
||||
setUsername('')
|
||||
setPlainPassword('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setNotice('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await registerUser({ email, username, plainPassword })
|
||||
setNotice(t('auth.verify_notice'))
|
||||
setEmail('')
|
||||
setUsername('')
|
||||
setPlainPassword('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container 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>
|
||||
<Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{notice && <p className="text-success">{notice}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={plainPassword}
|
||||
onChange={(event) => setPlainPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? t('form.registering') : t('form.create_account')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
return (
|
||||
<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>
|
||||
<Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{notice && <p className="text-success">{notice}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={plainPassword}
|
||||
onChange={(event) => setPlainPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? t('form.registering') : t('form.create_account')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,113 +1,305 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Container, Form } from 'react-bootstrap'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { createPost, getThread, listPostsByThread } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThreadView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const [thread, setThread] = useState(null)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const { token, userId } = useAuth()
|
||||
const [thread, setThread] = useState(null)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const replyRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([getThread(id), listPostsByThread(id)])
|
||||
.then(([threadData, postData]) => {
|
||||
setThread(threadData)
|
||||
setPosts(postData)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([getThread(id), listPostsByThread(id)])
|
||||
.then(([threadData, postData]) => {
|
||||
setThread(threadData)
|
||||
setPosts(postData)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await createPost({ body, threadId: id })
|
||||
setBody('')
|
||||
const updated = await listPostsByThread(id)
|
||||
setPosts(updated)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
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)
|
||||
setError('')
|
||||
try {
|
||||
await createPost({ body, threadId: id })
|
||||
setBody('')
|
||||
const updated = await listPostsByThread(id)
|
||||
setPosts(updated)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{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>
|
||||
// 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_id: thread.user_id,
|
||||
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_thanks_given_count: thread.user_thanks_given_count,
|
||||
user_thanks_received_count: thread.user_thanks_received_count,
|
||||
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 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-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>
|
||||
|
||||
<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 currentUserId = Number(userId)
|
||||
const postUserId = Number(post.user_id)
|
||||
const canThank = Number.isFinite(currentUserId)
|
||||
&& Number.isFinite(postUserId)
|
||||
&& currentUserId !== postUserId
|
||||
console.log('canThank check', {
|
||||
postId: post.id,
|
||||
postUserId,
|
||||
currentUserId,
|
||||
canThank,
|
||||
})
|
||||
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" />
|
||||
)}
|
||||
</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">{t('thread.thanks_given')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_given_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_received_count ?? 0}
|
||||
</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>
|
||||
{canThank && (
|
||||
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
|
||||
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-post-body">{post.body}</div>
|
||||
<div className="bb-post-footer">
|
||||
<div className="bb-post-actions">
|
||||
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-chevron-up" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.message')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={6}
|
||||
placeholder={t('form.reply_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
{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">
|
||||
{!token && (
|
||||
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.message')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder={t('form.reply_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
||||
{saving ? t('form.posting') : t('form.post_reply')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
179
resources/js/pages/Ucp.jsx
Normal file
179
resources/js/pages/Ucp.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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
|
||||
i18n.changeLanguage(locale)
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Row className="g-3">
|
||||
<Col xs={12}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('nav.language')}</Form.Label>
|
||||
<Form.Select value={i18n.language} onChange={handleLanguageChange}>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('nav.theme')}</Form.Label>
|
||||
<Form.Select value={theme} onChange={(event) => setTheme(event.target.value)}>
|
||||
<option value="auto">{t('ucp.system_default')}</option>
|
||||
<option value="dark">{t('nav.theme_dark')}</option>
|
||||
<option value="light">{t('nav.theme_light')}</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('ucp.accent_override')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Select
|
||||
value={accentMode}
|
||||
onChange={(event) => {
|
||||
const mode = event.target.value
|
||||
if (mode === 'system') {
|
||||
setAccentOverride('')
|
||||
} else if (!accentOverride) {
|
||||
setAccentOverride('#f29b3f')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="system">{t('ucp.system_default')}</option>
|
||||
<option value="custom">{t('ucp.custom_color')}</option>
|
||||
</Form.Select>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={accentOverride || '#f29b3f'}
|
||||
onChange={(event) => setAccentOverride(event.target.value)}
|
||||
disabled={accentMode !== 'custom'}
|
||||
/>
|
||||
</div>
|
||||
<Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -9,14 +9,43 @@
|
||||
"acp.forums": "Foren",
|
||||
"acp.forums_confirm_delete": "Dieses Forum löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"acp.forums_create_title": "Forum oder Kategorie erstellen",
|
||||
"acp.forums_create_category_title": "Kategorie erstellen",
|
||||
"acp.forums_create_forum_title": "Forum erstellen",
|
||||
"acp.forums_edit_title": "Forum bearbeiten",
|
||||
"acp.forums_edit_category_title": "Kategorie bearbeiten",
|
||||
"acp.forums_edit_forum_title": "Forum bearbeiten",
|
||||
"acp.forums_empty": "Noch keine Foren vorhanden. Lege rechts das erste an.",
|
||||
"acp.forums_form_empty_hint": "Wähle ein Forum zum Bearbeiten oder klicke auf Neue Kategorie / Neues Forum.",
|
||||
"acp.forums_form_empty_title": "Keine Auswahl",
|
||||
"acp.forums_form_hint": "Erstelle ein neues Forum oder bearbeite das ausgewählte. Kategorien können Foren und andere Kategorien enthalten.",
|
||||
"acp.forums_create_category_hint": "Erstelle eine neue Kategorie. Kategorien können Foren und andere Kategorien enthalten.",
|
||||
"acp.forums_create_forum_hint": "Erstelle ein neues Forum innerhalb einer Kategorie.",
|
||||
"acp.forums_edit_category_hint": "Aktualisiere die Kategorie. Kategorien können Foren und andere Kategorien enthalten.",
|
||||
"acp.forums_edit_forum_hint": "Aktualisiere die Forum-Details.",
|
||||
"acp.forums_hint": "Kategorien und Foren in einer Baumansicht verwalten.",
|
||||
"acp.forums_name_required": "Bitte zuerst einen Namen eingeben.",
|
||||
"acp.forums_parent": "Ãbergeordnete Kategorie",
|
||||
"acp.forums_parent_required": "Foren brauchen eine übergeordnete Kategorie.",
|
||||
"acp.forum_name": "Forenname",
|
||||
"acp.default_theme": "Standard-Design",
|
||||
"acp.accent_dark": "Akzentfarbe (dunkel)",
|
||||
"acp.accent_light": "Akzentfarbe (hell)",
|
||||
"acp.logo_dark": "Logo (dunkel)",
|
||||
"acp.logo_light": "Logo (hell)",
|
||||
"acp.logo_upload": "Logo hochladen",
|
||||
"acp.favicons": "Favicons",
|
||||
"acp.favicon_ico": "Favicon (ICO)",
|
||||
"acp.favicon_16": "Favicon 16x16",
|
||||
"acp.favicon_32": "Favicon 32x32",
|
||||
"acp.favicon_48": "Favicon 48x48",
|
||||
"acp.favicon_64": "Favicon 64x64",
|
||||
"acp.favicon_128": "Favicon 128x128",
|
||||
"acp.favicon_256": "Favicon 256x256",
|
||||
"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.groups": "Gruppen",
|
||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||
"acp.forums_tree": "Forenbaum",
|
||||
"acp.forums_type": "Typ",
|
||||
@@ -31,9 +60,10 @@
|
||||
"acp.save": "Speichern",
|
||||
"acp.title": "Administrationsbereich",
|
||||
"acp.users": "Benutzer",
|
||||
"app.brand": "speedBB",
|
||||
"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",
|
||||
@@ -51,6 +81,7 @@
|
||||
"form.reply_placeholder": "Schreibe deine Antwort.",
|
||||
"form.sign_in": "Anmelden",
|
||||
"form.signing_in": "Anmeldung läuft...",
|
||||
"form.saving": "Speichern...",
|
||||
"form.thread_body_placeholder": "Teile den Kontext und deine Frage.",
|
||||
"form.thread_title_placeholder": "Thema",
|
||||
"form.title": "Titel",
|
||||
@@ -60,21 +91,55 @@
|
||||
"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.start_thread": "Thread starten",
|
||||
"forum.collapse_category": "Kategorie einklappen",
|
||||
"forum.expand_category": "Kategorie ausklappen",
|
||||
"forum.start_thread": "Neues Thema",
|
||||
"forum.threads": "Threads",
|
||||
"forum.type_category": "Kategorie",
|
||||
"forum.type_forum": "Forum",
|
||||
"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_none": "Kein Badge",
|
||||
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
|
||||
"rank.color": "Rangfarbe",
|
||||
"rank.color_placeholder": "z. B. #f29b3f",
|
||||
"rank.color_default": "Standardfarbe verwenden",
|
||||
"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",
|
||||
"user.edit": "Bearbeiten",
|
||||
"user.delete": "Löschen",
|
||||
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
|
||||
"group.create": "Gruppe erstellen",
|
||||
"group.create_title": "Gruppe erstellen",
|
||||
"group.edit_title": "Gruppe bearbeiten",
|
||||
"group.name": "Gruppenname",
|
||||
"group.name_placeholder": "z. B. Gründer",
|
||||
"group.color": "Gruppenfarbe",
|
||||
"group.color_placeholder": "z. B. #f29b3f",
|
||||
"group.delete_confirm": "Diese Gruppe löschen?",
|
||||
"group.empty": "Noch keine Gruppen vorhanden.",
|
||||
"group.edit": "Bearbeiten",
|
||||
"group.delete": "Löschen",
|
||||
"group.core_locked": "Kern-Gruppen können nicht geändert werden.",
|
||||
"table.rows_per_page": "Zeilen pro Seite:",
|
||||
"table.range_separator": "von",
|
||||
"home.browse": "Foren durchsuchen",
|
||||
@@ -82,16 +147,70 @@
|
||||
"home.hero_body": "Entdecke Diskussionen, stelle Fragen und teile Ideen in Kategorien und Foren.",
|
||||
"home.hero_title": "Foren",
|
||||
"home.loading": "Foren werden geladen...",
|
||||
"nav.acp": "ACP",
|
||||
"nav.forums": "Foren",
|
||||
"nav.language": "Sprache",
|
||||
"nav.login": "Anmelden",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.register": "Registrieren",
|
||||
"nav.theme": "Design",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.theme_dark": "Dunkel",
|
||||
"nav.theme_light": "Hell",
|
||||
"portal.portal": "Portal",
|
||||
"portal.tagline": "Demo forum",
|
||||
"portal.search_placeholder": "Suche...",
|
||||
"portal.quick_links": "Quicklinks",
|
||||
"portal.link_faq": "FAQ",
|
||||
"portal.link_acp": "ACP",
|
||||
"portal.link_mcp": "MCP",
|
||||
"portal.board_index": "Foren-Übersicht",
|
||||
"portal.notifications": "Benachrichtigungen",
|
||||
"portal.messages": "Private Nachrichten",
|
||||
"portal.menu": "Menü",
|
||||
"portal.menu_news": "News",
|
||||
"portal.menu_gallery": "Galerie",
|
||||
"portal.menu_calendar": "Kalender",
|
||||
"portal.menu_rules": "Forenregeln",
|
||||
"portal.stats": "Statistik",
|
||||
"portal.stat_threads": "Themen",
|
||||
"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ü",
|
||||
"portal.user_new_posts": "Neue Beiträge",
|
||||
"portal.user_unread": "Ungelesene Beiträge",
|
||||
"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:",
|
||||
"profile.thanks_given": "Hat sich bedankt",
|
||||
"profile.thanks_received": "Dank erhalten",
|
||||
"profile.thanks_empty": "Noch keine Danksagungen.",
|
||||
"profile.thanks_by": "Dank von",
|
||||
"profile.thanks_for": "Dank für",
|
||||
"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.",
|
||||
"ucp.custom_color": "Eigene Farbe",
|
||||
"thread.anonymous": "Anonym",
|
||||
"thread.back_to_category": "Zurück zum Forum",
|
||||
"thread.category": "Forum:",
|
||||
@@ -99,7 +218,18 @@
|
||||
"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.thanks_given": "Hat sich bedankt",
|
||||
"thread.thanks_received": "Dank erhalten",
|
||||
"thread.thanks": "Danke",
|
||||
"thread.reply_prefix": "Aw:",
|
||||
"thread.registered": "Registriert",
|
||||
"thread.replies": "Antworten",
|
||||
"thread.views": "Zugriffe",
|
||||
"thread.last_post": "Letzter Beitrag",
|
||||
"thread.by": "von",
|
||||
"thread.no_replies": "Noch keine Antworten.",
|
||||
"thread.reply": "Antworten",
|
||||
"thread.view": "Thread ansehen"
|
||||
}
|
||||
|
||||
@@ -9,14 +9,43 @@
|
||||
"acp.forums": "Forums",
|
||||
"acp.forums_confirm_delete": "Delete this forum? This cannot be undone.",
|
||||
"acp.forums_create_title": "Create forum or category",
|
||||
"acp.forums_create_category_title": "Create category",
|
||||
"acp.forums_create_forum_title": "Create forum",
|
||||
"acp.forums_edit_title": "Edit forum",
|
||||
"acp.forums_edit_category_title": "Edit category",
|
||||
"acp.forums_edit_forum_title": "Edit forum",
|
||||
"acp.forums_empty": "No forums yet. Create the first one on the right.",
|
||||
"acp.forums_form_empty_hint": "Choose a forum to edit or click New category / New forum to create one.",
|
||||
"acp.forums_form_empty_title": "No selection",
|
||||
"acp.forums_form_hint": "Create a new forum or edit the selected one. Categories can contain forums and other categories.",
|
||||
"acp.forums_create_category_hint": "Create a new category. Categories can contain forums and other categories.",
|
||||
"acp.forums_create_forum_hint": "Create a new forum within a category.",
|
||||
"acp.forums_edit_category_hint": "Update the category details. Categories can contain forums and other categories.",
|
||||
"acp.forums_edit_forum_hint": "Update the forum details.",
|
||||
"acp.forums_hint": "Manage categories and forums from a tree view.",
|
||||
"acp.forums_name_required": "Please enter a name before saving.",
|
||||
"acp.forums_parent": "Parent category",
|
||||
"acp.forums_parent_required": "Forums must have a parent category.",
|
||||
"acp.forum_name": "Forum name",
|
||||
"acp.default_theme": "Default theme",
|
||||
"acp.accent_dark": "Accent color (dark)",
|
||||
"acp.accent_light": "Accent color (light)",
|
||||
"acp.logo_dark": "Logo (dark)",
|
||||
"acp.logo_light": "Logo (light)",
|
||||
"acp.logo_upload": "Upload logo",
|
||||
"acp.favicons": "Favicons",
|
||||
"acp.favicon_ico": "Favicon (ICO)",
|
||||
"acp.favicon_16": "Favicon 16x16",
|
||||
"acp.favicon_32": "Favicon 32x32",
|
||||
"acp.favicon_48": "Favicon 48x48",
|
||||
"acp.favicon_64": "Favicon 64x64",
|
||||
"acp.favicon_128": "Favicon 128x128",
|
||||
"acp.favicon_256": "Favicon 256x256",
|
||||
"acp.show_header_name": "Display name in header",
|
||||
"acp.add_category": "Add category",
|
||||
"acp.add_forum": "Add forum",
|
||||
"acp.ranks": "Ranks",
|
||||
"acp.groups": "Groups",
|
||||
"acp.forums_parent_root": "Root (no parent)",
|
||||
"acp.forums_tree": "Forum tree",
|
||||
"acp.forums_type": "Type",
|
||||
@@ -31,9 +60,10 @@
|
||||
"acp.save": "Save",
|
||||
"acp.title": "Admin control panel",
|
||||
"acp.users": "Users",
|
||||
"app.brand": "speedBB",
|
||||
"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",
|
||||
@@ -51,6 +81,7 @@
|
||||
"form.reply_placeholder": "Share your reply.",
|
||||
"form.sign_in": "Sign in",
|
||||
"form.signing_in": "Signing in...",
|
||||
"form.saving": "Saving...",
|
||||
"form.thread_body_placeholder": "Share the context and your question.",
|
||||
"form.thread_title_placeholder": "Topic headline",
|
||||
"form.title": "Title",
|
||||
@@ -60,21 +91,55 @@
|
||||
"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.start_thread": "Start a thread",
|
||||
"forum.collapse_category": "Collapse category",
|
||||
"forum.expand_category": "Expand category",
|
||||
"forum.start_thread": "New topic",
|
||||
"forum.threads": "Threads",
|
||||
"forum.type_category": "Category",
|
||||
"forum.type_forum": "Forum",
|
||||
"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_none": "No badge",
|
||||
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
|
||||
"rank.color": "Rank color",
|
||||
"rank.color_placeholder": "e.g. #f29b3f",
|
||||
"rank.color_default": "Use default color",
|
||||
"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",
|
||||
"user.edit": "Edit",
|
||||
"user.delete": "Delete",
|
||||
"user.founder_locked": "Only founders can edit or assign the Founder role.",
|
||||
"group.create": "Create group",
|
||||
"group.create_title": "Create group",
|
||||
"group.edit_title": "Edit group",
|
||||
"group.name": "Group name",
|
||||
"group.name_placeholder": "e.g. Founder",
|
||||
"group.color": "Group color",
|
||||
"group.color_placeholder": "e.g. #f29b3f",
|
||||
"group.delete_confirm": "Delete this group?",
|
||||
"group.empty": "No groups created yet.",
|
||||
"group.edit": "Edit",
|
||||
"group.delete": "Delete",
|
||||
"group.core_locked": "Core groups cannot be changed.",
|
||||
"table.rows_per_page": "Rows per page:",
|
||||
"table.range_separator": "of",
|
||||
"home.browse": "Browse forums",
|
||||
@@ -82,16 +147,70 @@
|
||||
"home.hero_body": "Explore conversations, ask questions, and share ideas across categories and forums.",
|
||||
"home.hero_title": "Forums",
|
||||
"home.loading": "Loading forums...",
|
||||
"nav.acp": "ACP",
|
||||
"nav.forums": "Forums",
|
||||
"nav.language": "Language",
|
||||
"nav.login": "Login",
|
||||
"nav.logout": "Logout",
|
||||
"nav.register": "Register",
|
||||
"nav.theme": "Theme",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.theme_dark": "Dark",
|
||||
"nav.theme_light": "Light",
|
||||
"portal.portal": "Portal",
|
||||
"portal.tagline": "Demo forum",
|
||||
"portal.search_placeholder": "Search...",
|
||||
"portal.quick_links": "Quick links",
|
||||
"portal.link_faq": "FAQ",
|
||||
"portal.link_acp": "ACP",
|
||||
"portal.link_mcp": "MCP",
|
||||
"portal.board_index": "Board index",
|
||||
"portal.notifications": "Notifications",
|
||||
"portal.messages": "Private messages",
|
||||
"portal.menu": "Menu",
|
||||
"portal.menu_news": "News",
|
||||
"portal.menu_gallery": "Gallery",
|
||||
"portal.menu_calendar": "Calendar",
|
||||
"portal.menu_rules": "Forum rules",
|
||||
"portal.stats": "Statistics",
|
||||
"portal.stat_threads": "Threads",
|
||||
"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",
|
||||
"portal.user_new_posts": "New posts",
|
||||
"portal.user_unread": "Unread posts",
|
||||
"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:",
|
||||
"profile.thanks_given": "Thanks given",
|
||||
"profile.thanks_received": "Thanks received",
|
||||
"profile.thanks_empty": "No thanks yet.",
|
||||
"profile.thanks_by": "Thanks by",
|
||||
"profile.thanks_for": "Thanks for",
|
||||
"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.",
|
||||
"ucp.custom_color": "Custom color",
|
||||
"thread.anonymous": "Anonymous",
|
||||
"thread.back_to_category": "Back to forum",
|
||||
"thread.category": "Forum:",
|
||||
@@ -99,7 +218,18 @@
|
||||
"thread.label": "Thread",
|
||||
"thread.loading": "Loading thread...",
|
||||
"thread.login_hint": "Log in to reply to this thread.",
|
||||
"thread.posts": "Posts",
|
||||
"thread.location": "Location",
|
||||
"thread.thanks_given": "Thanks given",
|
||||
"thread.thanks_received": "Thanks received",
|
||||
"thread.thanks": "Thank",
|
||||
"thread.reply_prefix": "Re:",
|
||||
"thread.registered": "Registered",
|
||||
"thread.replies": "Replies",
|
||||
"thread.views": "Views",
|
||||
"thread.last_post": "Last post",
|
||||
"thread.by": "by",
|
||||
"thread.no_replies": "No replies yet.",
|
||||
"thread.reply": "Reply",
|
||||
"thread.view": "View thread"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@vite(['resources/js/main.jsx'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="top"></div>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
55
resources/views/installer-success.blade.php
Normal file
55
resources/views/installer-success.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>speedBB Installed</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: #0b0f17;
|
||||
color: #e6e8eb;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2rem 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: rgba(18, 23, 33, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #9aa4b2;
|
||||
}
|
||||
a {
|
||||
display: inline-flex;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
background: #ff8a3d;
|
||||
color: #1a1a1a;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Installation complete</h1>
|
||||
<p>Your forum is ready. You can now log in with the founder account.</p>
|
||||
<a href="/">Go to forum</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
165
resources/views/installer.blade.php
Normal file
165
resources/views/installer.blade.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>speedBB Installer</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: #0b0f17;
|
||||
color: #e6e8eb;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2rem 1rem 4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
background: rgba(18, 23, 33, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #9aa4b2;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(12, 16, 24, 0.9);
|
||||
color: #e6e8eb;
|
||||
min-width: 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.grid--wide {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
.error {
|
||||
background: rgba(220, 80, 80, 0.15);
|
||||
border: 1px solid rgba(220, 80, 80, 0.4);
|
||||
color: #ffb4b4;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
button {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #ff8a3d;
|
||||
color: #1a1a1a;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>speedBB Installer</h1>
|
||||
<p>Provide database details and create the first founder/admin account.</p>
|
||||
|
||||
@if (!empty($error))
|
||||
<div class="error">{{ $error }}</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="error">
|
||||
<ul>
|
||||
@foreach ($errors->all() as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="/install">
|
||||
@csrf
|
||||
<div class="section">
|
||||
<h2>Application</h2>
|
||||
<label for="app_url">App URL</label>
|
||||
<input id="app_url" name="app_url" type="url" required value="{{ old('app_url', $old['app_url'] ?? $appUrl) }}" />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Database (MySQL/MariaDB)</h2>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="db_host">Host</label>
|
||||
<input id="db_host" name="db_host" required value="{{ old('db_host', $old['db_host'] ?? '127.0.0.1') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_port">Port</label>
|
||||
<input id="db_port" name="db_port" type="number" value="{{ old('db_port', $old['db_port'] ?? 3306) }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_database">Database</label>
|
||||
<input id="db_database" name="db_database" required value="{{ old('db_database', $old['db_database'] ?? '') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_username">Username</label>
|
||||
<input id="db_username" name="db_username" required value="{{ old('db_username', $old['db_username'] ?? '') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_password">Password</label>
|
||||
<input id="db_password" name="db_password" type="password" value="{{ old('db_password', $old['db_password'] ?? '') }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Founder Account</h2>
|
||||
<div class="grid grid--wide">
|
||||
<div>
|
||||
<label for="admin_name">Username</label>
|
||||
<input id="admin_name" name="admin_name" required value="{{ old('admin_name', $old['admin_name'] ?? '') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin_email">Email</label>
|
||||
<input id="admin_email" name="admin_email" type="email" required value="{{ old('admin_email', $old['admin_email'] ?? '') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin_password">Password</label>
|
||||
<input id="admin_password" name="admin_password" type="password" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Install speedBB</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,21 +3,59 @@
|
||||
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\PostThankController;
|
||||
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 App\Http\Controllers\RoleController;
|
||||
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');
|
||||
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
||||
Route::post('/uploads/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('/roles', [RoleController::class, 'index'])->middleware('auth:sanctum');
|
||||
Route::post('/roles', [RoleController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::patch('/roles/{role}', [RoleController::class, 'update'])->middleware('auth:sanctum');
|
||||
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
Route::get('/user/{user}/thanks/given', [PostThankController::class, 'given'])->middleware('auth:sanctum');
|
||||
Route::get('/user/{user}/thanks/received', [PostThankController::class, 'received'])->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']);
|
||||
@@ -33,4 +71,6 @@ Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middle
|
||||
|
||||
Route::get('/posts', [PostController::class, 'index']);
|
||||
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'app');
|
||||
Route::get('/install', [InstallerController::class, 'show']);
|
||||
Route::post('/install', [InstallerController::class, 'store'])
|
||||
->withoutMiddleware([VerifyCsrfToken::class]);
|
||||
|
||||
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
|
||||
Route::get('/', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
});
|
||||
|
||||
Route::get('/login', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
})->name('login');
|
||||
|
||||
Route::get('/reset-password', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
})->name('password.reset');
|
||||
|
||||
Route::get('/{any}', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
})->where('any', '^(?!api).*$');
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SpeedBB</title>
|
||||
<?php echo app('Illuminate\Foundation\Vite')->reactRefresh(); ?>
|
||||
<?php echo app('Illuminate\Foundation\Vite')(['resources/js/main.jsx']); ?>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<?php /**PATH /home/users/tracer/www/forum.lab.24unix.net/speedBB/resources/views/app.blade.php ENDPATH**/ ?>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user