Compare commits
3 Commits
master
...
acp-cleanu
| Author | SHA1 | Date | |
|---|---|---|---|
| fe1015bff1 | |||
|
|
8604cdf95d | ||
| f83748cc76 |
21
CHANGELOG.md
@@ -35,3 +35,24 @@
|
||||
- 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-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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,7 @@ class ForumController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Forum::query();
|
||||
$query = Forum::query()->withoutTrashed();
|
||||
|
||||
$parentParam = $request->query('parent');
|
||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
@@ -57,6 +57,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') {
|
||||
@@ -87,6 +91,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) {
|
||||
@@ -115,8 +125,10 @@ class ForumController extends Controller
|
||||
return response()->json($this->serializeForum($forum));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -11,7 +11,7 @@ class PostController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Post::query();
|
||||
$query = Post::query()->withoutTrashed();
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
if (is_string($threadParam)) {
|
||||
@@ -48,8 +48,10 @@ class PostController extends Controller
|
||||
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);
|
||||
|
||||
@@ -24,4 +24,30 @@ 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class ThreadController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Thread::query();
|
||||
$query = Thread::query()->withoutTrashed()->with('user');
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
if (is_string($forumParam)) {
|
||||
@@ -31,6 +31,7 @@ class ThreadController extends Controller
|
||||
|
||||
public function show(Thread $thread): JsonResponse
|
||||
{
|
||||
$thread->loadMissing('user');
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
@@ -59,8 +60,10 @@ class ThreadController extends Controller
|
||||
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,6 +94,7 @@ class ThreadController extends Controller
|
||||
'body' => $thread->body,
|
||||
'forum' => "/api/forums/{$thread->forum_id}",
|
||||
'user_id' => $thread->user_id,
|
||||
'user_name' => $thread->user?->name,
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
48
app/Http/Controllers/UploadController.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UploadController extends Controller
|
||||
{
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -35,6 +36,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Forum extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -27,6 +28,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Post extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'thread_id',
|
||||
'user_id',
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -32,6 +33,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Thread extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'forum_id',
|
||||
'user_id',
|
||||
|
||||
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,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');
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder
|
||||
RoleSeeder::class,
|
||||
UserSeeder::class,
|
||||
ForumSeeder::class,
|
||||
ThreadSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
},
|
||||
|
||||
@@ -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,96 +8,218 @@ 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 { useTranslation } from 'react-i18next'
|
||||
import { fetchSetting, fetchVersion } from './api/client'
|
||||
import { fetchSetting, 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 }) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
}
|
||||
|
||||
buildCrumbs()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [location.pathname, t])
|
||||
|
||||
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 && (
|
||||
<Container className="pt-2 pb-2 bb-portal-shell">
|
||||
<div className="bb-portal-banner">
|
||||
<div className="bb-portal-brand">
|
||||
{logoUrl && (
|
||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||
)}
|
||||
{(showHeaderName || !logoUrl) && (
|
||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||
)}
|
||||
</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>
|
||||
<Link to="/acp" className="bb-portal-link">
|
||||
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
||||
</Link>
|
||||
<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 ? (
|
||||
<>
|
||||
<Nav.Link as={Link} to="/login">
|
||||
{t('nav.login')}
|
||||
</Nav.Link>
|
||||
<Nav.Link as={Link} to="/register">
|
||||
{t('nav.register')}
|
||||
</Nav.Link>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
</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 ? (
|
||||
<span className="bb-portal-current">
|
||||
{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}
|
||||
</span>
|
||||
) : (
|
||||
<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 { token, email, logout, isAdmin } = useAuth()
|
||||
const [versionInfo, setVersionInfo] = useState(null)
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
|
||||
|
||||
useEffect(() => {
|
||||
const [entry] = performance.getEntriesByType('navigation')
|
||||
if (entry?.duration) {
|
||||
setLoadMs(Math.round(entry.duration))
|
||||
return
|
||||
}
|
||||
setLoadMs(Math.round(performance.now()))
|
||||
}, [])
|
||||
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(() => {
|
||||
fetchVersion()
|
||||
@@ -106,24 +228,107 @@ function AppShell() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSetting('accent_color')
|
||||
.then((setting) => {
|
||||
if (setting?.value) {
|
||||
document.documentElement.style.setProperty('--bb-accent', setting.value)
|
||||
let active = true
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const [
|
||||
forumNameSetting,
|
||||
defaultThemeSetting,
|
||||
accentDarkSetting,
|
||||
accentLightSetting,
|
||||
logoDarkSetting,
|
||||
logoLightSetting,
|
||||
showHeaderNameSetting,
|
||||
faviconIcoSetting,
|
||||
favicon16Setting,
|
||||
favicon32Setting,
|
||||
favicon48Setting,
|
||||
favicon64Setting,
|
||||
favicon128Setting,
|
||||
favicon256Setting,
|
||||
] = await Promise.all([
|
||||
fetchSetting('forum_name'),
|
||||
fetchSetting('default_theme'),
|
||||
fetchSetting('accent_color_dark'),
|
||||
fetchSetting('accent_color_light'),
|
||||
fetchSetting('logo_dark'),
|
||||
fetchSetting('logo_light'),
|
||||
fetchSetting('show_header_name'),
|
||||
fetchSetting('favicon_ico'),
|
||||
fetchSetting('favicon_16'),
|
||||
fetchSetting('favicon_32'),
|
||||
fetchSetting('favicon_48'),
|
||||
fetchSetting('favicon_64'),
|
||||
fetchSetting('favicon_128'),
|
||||
fetchSetting('favicon_256'),
|
||||
])
|
||||
if (!active) return
|
||||
const next = {
|
||||
forumName: forumNameSetting?.value || '',
|
||||
defaultTheme: defaultThemeSetting?.value || 'auto',
|
||||
accentDark: accentDarkSetting?.value || '',
|
||||
accentLight: accentLightSetting?.value || '',
|
||||
logoDark: logoDarkSetting?.value || '',
|
||||
logoLight: logoLightSetting?.value || '',
|
||||
showHeaderName: showHeaderNameSetting?.value !== 'false',
|
||||
faviconIco: faviconIcoSetting?.value || '',
|
||||
favicon16: favicon16Setting?.value || '',
|
||||
favicon32: favicon32Setting?.value || '',
|
||||
favicon48: favicon48Setting?.value || '',
|
||||
favicon64: favicon64Setting?.value || '',
|
||||
favicon128: favicon128Setting?.value || '',
|
||||
favicon256: favicon256Setting?.value || '',
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
setSettings(next)
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = token ? localStorage.getItem('speedbb_theme') : null
|
||||
const nextTheme = stored || settings.defaultTheme || 'auto'
|
||||
setTheme(nextTheme)
|
||||
}, [token, settings.defaultTheme])
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsUpdate = (event) => {
|
||||
const next = event.detail
|
||||
if (!next) return
|
||||
setSettings((prev) => ({ ...prev, ...next }))
|
||||
}
|
||||
|
||||
window.addEventListener('speedbb-settings-updated', handleSettingsUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (accentOverride) {
|
||||
localStorage.setItem('speedbb_accent', accentOverride)
|
||||
} else {
|
||||
localStorage.removeItem('speedbb_accent')
|
||||
}
|
||||
}, [accentOverride])
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const applyTheme = (mode) => {
|
||||
if (mode === 'auto') {
|
||||
root.setAttribute('data-bs-theme', media.matches ? 'dark' : 'light')
|
||||
const next = media.matches ? 'dark' : 'light'
|
||||
root.setAttribute('data-bs-theme', next)
|
||||
setResolvedTheme(next)
|
||||
} else {
|
||||
root.setAttribute('data-bs-theme', mode)
|
||||
setResolvedTheme(mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,16 +347,121 @@ function AppShell() {
|
||||
}
|
||||
}, [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">
|
||||
<Navigation theme={theme} onThemeChange={setTheme} />
|
||||
<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="/ucp">
|
||||
<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
|
||||
}
|
||||
/>
|
||||
<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="/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">
|
||||
@@ -165,12 +475,6 @@ function AppShell() {
|
||||
<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>
|
||||
|
||||
@@ -75,10 +75,54 @@ export async function fetchVersion() {
|
||||
}
|
||||
|
||||
export async function fetchSetting(key) {
|
||||
const data = await getCollection(`/settings?key=${encodeURIComponent(key)}&pagination=false`)
|
||||
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 saveSetting(key, value) {
|
||||
return apiFetch('/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
@@ -134,6 +178,10 @@ export async function listThreadsByForum(forumId) {
|
||||
return getCollection(`/threads?forum=/api/forums/${forumId}`)
|
||||
}
|
||||
|
||||
export async function listThreads() {
|
||||
return getCollection('/threads')
|
||||
}
|
||||
|
||||
export async function getThread(id) {
|
||||
return apiFetch(`/threads/${id}`)
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ a {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bb-nav {
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(247, 242, 234, 0.9);
|
||||
border-bottom: 1px solid var(--bb-border);
|
||||
}
|
||||
|
||||
.bb-hero {
|
||||
background: linear-gradient(135deg, rgba(21, 122, 110, 0.08), rgba(228, 166, 52, 0.1));
|
||||
border: 1px solid var(--bb-border);
|
||||
@@ -123,10 +117,6 @@ a {
|
||||
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-nav {
|
||||
background: rgba(15, 18, 26, 0.9);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-hero {
|
||||
background: linear-gradient(135deg, rgba(21, 122, 110, 0.12), rgba(228, 166, 52, 0.08));
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
@@ -199,16 +189,962 @@ a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bb-load-time {
|
||||
.bb-acp-action.btn-outline-dark {
|
||||
--bs-btn-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-hover-color: #0f1218;
|
||||
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-hover-border-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-active-color: #0f1218;
|
||||
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-focus-shadow-rgb: 242, 155, 63;
|
||||
color: var(--bb-accent, #f29b3f) !important;
|
||||
border-color: var(--bb-accent, #f29b3f) !important;
|
||||
}
|
||||
|
||||
.bb-acp-action--active.btn-outline-dark {
|
||||
background-color: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
color: #0f1218;
|
||||
}
|
||||
|
||||
.bb-acp-action.btn-outline-dark:hover,
|
||||
.bb-acp-action.btn-outline-dark:focus {
|
||||
background-color: var(--bb-accent, #f29b3f) !important;
|
||||
border-color: var(--bb-accent, #f29b3f) !important;
|
||||
color: #0f1218 !important;
|
||||
}
|
||||
|
||||
.bb-acp-general {
|
||||
margin-top: 1rem;
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.bb-dropzone {
|
||||
min-height: 120px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.16);
|
||||
background: rgba(20, 25, 36, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.bb-dropzone:hover {
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-dropzone-placeholder {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-dropzone-placeholder i {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bb-dropzone-preview img {
|
||||
max-height: 80px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bb-acp-accordion .accordion-item {
|
||||
background: rgba(20, 25, 36, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.bb-acp-accordion .accordion-header .accordion-button {
|
||||
background: transparent;
|
||||
color: var(--bb-ink);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.bb-acp-accordion .accordion-button:not(.collapsed) {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-acp-accordion .accordion-button::after {
|
||||
background-image: none;
|
||||
background-color: var(--bb-accent, #f29b3f);
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat center / contain;
|
||||
-webkit-mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat center / contain;
|
||||
}
|
||||
|
||||
.bb-acp-accordion .accordion-body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
|
||||
--bs-btn-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-hover-color: #0f1218;
|
||||
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-hover-border-color: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-active-color: #0f1218;
|
||||
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
|
||||
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-accent, #f29b3f) !important;
|
||||
border-color: var(--bb-accent, #f29b3f) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-acp-action--active.btn-outline-dark {
|
||||
background-color: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
color: #0f1218;
|
||||
}
|
||||
|
||||
|
||||
.bb-topic-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(16, 20, 29, 0.9), rgba(12, 15, 22, 0.9));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.bb-topic-toolbar__left,
|
||||
.bb-topic-toolbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bb-topic-action {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-load-label {
|
||||
color: inherit;
|
||||
.bb-topic-count {
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-load-value {
|
||||
color: #fff;
|
||||
.bb-topic-pagination {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.bb-topic-pagination .btn {
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-topic-pagination .btn.is-active {
|
||||
background: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
color: #0f1218;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bb-topic-table {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 18, 26, 0.75);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.bb-topic-header,
|
||||
.bb-topic-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 90px 90px 180px;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bb-topic-header {
|
||||
padding: 0.7rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-topic-row {
|
||||
padding: 0.9rem 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.bb-topic-cell--replies,
|
||||
.bb-topic-cell--views,
|
||||
.bb-topic-cell--last {
|
||||
text-align: center;
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-topic-last {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
align-items: flex-start;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bb-topic-last-by {
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bb-topic-title a {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-topic-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-topic-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.bb-topic-title a:hover {
|
||||
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
|
||||
}
|
||||
|
||||
.bb-topic-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bb-ink-muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.bb-topic-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bb-topic-meta i {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-topic-author {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-topic-date {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-topic-meta .badge {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--bb-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-topic-snippet {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-topic-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 18, 26, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bb-breadcrumb__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bb-breadcrumb__sep {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-breadcrumb__link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-breadcrumb__link:hover {
|
||||
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
|
||||
}
|
||||
|
||||
.bb-breadcrumb__current {
|
||||
color: var(--bb-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-shell {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.bb-portal-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem 1.6rem;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, rgba(22, 28, 40, 0.9), rgba(12, 15, 22, 0.9));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.bb-portal-brand {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-portal-logo {
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bb-portal-logo-image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.bb-portal-tagline {
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bb-portal-search {
|
||||
position: relative;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.bb-portal-search input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 2rem 0.4rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--bb-ink);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-portal-search input::placeholder {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-search-icon {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-bars {
|
||||
margin-top: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bb-portal-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 25, 36, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-portal-bar--top {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.bb-portal-bar--bottom {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.bb-portal-bar-title {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.bb-portal-bar-title i,
|
||||
.bb-portal-bar-links i {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.bb-portal-bar-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.bb-portal-bar-links,
|
||||
.bb-portal-user-links {
|
||||
display: inline-flex;
|
||||
gap: 0.8rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-bar-links span {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-portal-user-links--guest {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-portal-user-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-user-link i {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.bb-portal-user-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-user-links i {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.bb-portal-user-links span {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-user-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-user-menu__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bb-ink);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bb-user-menu__name {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-user-menu__dropdown .dropdown-menu {
|
||||
background: rgba(24, 29, 40, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bb-user-menu__dropdown .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bb-user-menu__dropdown .dropdown-item {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bb-user-menu__dropdown .dropdown-item:hover,
|
||||
.bb-user-menu__dropdown .dropdown-item:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-user-menu__dropdown .dropdown-divider {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.bb-portal-breadcrumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bb-portal-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-link i {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bb-portal-current i {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bb-portal-sep {
|
||||
color: var(--bb-ink-muted);
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.bb-portal-current {
|
||||
color: var(--bb-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-breadcrumb .bb-portal-current {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-portal-layout {
|
||||
margin-top: 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr) 220px;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.bb-board-index {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.bb-board-section {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(18, 23, 33, 0.9);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bb-board-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-board-section__controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-board-section__title {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bb-board-section__cols {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 90px 180px;
|
||||
gap: 0.6rem;
|
||||
text-align: center;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.bb-board-toggle {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bb-board-toggle:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-board-section__body {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.bb-board-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 90px 90px 180px;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.bb-board-cell--topics,
|
||||
.bb-board-cell--posts,
|
||||
.bb-board-cell--last {
|
||||
text-align: center;
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bb-board-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bb-board-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.bb-board-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-board-link:hover {
|
||||
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
|
||||
}
|
||||
|
||||
.bb-board-desc {
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.bb-board-subforums {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-board-subforum-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-board-subforum-link:hover {
|
||||
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
|
||||
}
|
||||
|
||||
.bb-board-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.bb-board-section__cols,
|
||||
.bb-board-row {
|
||||
grid-template-columns: minmax(0, 1fr) 70px 70px 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bb-board-section__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bb-board-section__cols,
|
||||
.bb-board-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.bb-board-cell--topics,
|
||||
.bb-board-cell--posts,
|
||||
.bb-board-cell--last {
|
||||
text-align: left;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bb-portal-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bb-portal-card {
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(18, 23, 33, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bb-portal-card-title {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.bb-portal-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-list li {
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.bb-portal-list li:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--bb-ink);
|
||||
}
|
||||
|
||||
.bb-portal-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bb-ink-muted);
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.bb-portal-stat strong {
|
||||
color: var(--bb-ink);
|
||||
}
|
||||
|
||||
.bb-portal-topic-table {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bb-portal-topic-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 90px 90px 160px;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 90px 90px 160px;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.bb-portal-topic-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-portal-topic-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-title {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-meta {
|
||||
margin-top: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-forum {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-portal-topic-forum-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-forum-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-portal-topic-cell {
|
||||
text-align: center;
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-user-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.bb-portal-user-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.bb-portal-user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-user-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.bb-portal-card--ad {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bb-portal-ad-box {
|
||||
margin-top: 0.6rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 3rem 1rem;
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.bb-portal-layout {
|
||||
grid-template-columns: 200px minmax(0, 1fr) 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.bb-portal-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bb-portal-banner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bb-portal-search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.bb-topic-header,
|
||||
.bb-topic-row {
|
||||
grid-template-columns: minmax(0, 1fr) 70px 70px 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bb-topic-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bb-topic-header,
|
||||
.bb-topic-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.bb-topic-cell--replies,
|
||||
.bb-topic-cell--views,
|
||||
.bb-topic-cell--last {
|
||||
text-align: left;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bb-user-actions {
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
|
||||
import DataTable, { createTheme } from 'react-data-table-component'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createForum, deleteForum, listAllForums, listUsers, reorderForums, updateForum } from '../api/client'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import {
|
||||
createForum,
|
||||
deleteForum,
|
||||
fetchSetting,
|
||||
listAllForums,
|
||||
listUsers,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
uploadFavicon,
|
||||
uploadLogo,
|
||||
updateForum,
|
||||
} from '../api/client'
|
||||
|
||||
export default function Acp({ isAdmin }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -12,12 +24,32 @@ export default function Acp({ isAdmin }) {
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [overId, setOverId] = useState(null)
|
||||
const pendingOrder = useRef(null)
|
||||
const [createType, setCreateType] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [usersError, setUsersError] = useState('')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||
const [generalSaving, setGeneralSaving] = useState(false)
|
||||
const [generalUploading, setGeneralUploading] = useState(false)
|
||||
const [generalError, setGeneralError] = useState('')
|
||||
const [generalSettings, setGeneralSettings] = useState({
|
||||
forumName: '',
|
||||
defaultTheme: 'auto',
|
||||
darkAccent: '',
|
||||
lightAccent: '',
|
||||
darkLogo: '',
|
||||
lightLogo: '',
|
||||
showHeaderName: true,
|
||||
faviconIco: '',
|
||||
favicon16: '',
|
||||
favicon32: '',
|
||||
favicon48: '',
|
||||
favicon64: '',
|
||||
favicon128: '',
|
||||
favicon256: '',
|
||||
})
|
||||
const [themeMode, setThemeMode] = useState(
|
||||
document.documentElement.getAttribute('data-bs-theme') || 'light'
|
||||
)
|
||||
@@ -68,6 +100,242 @@ export default function Acp({ isAdmin }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
let active = true
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const keys = [
|
||||
'forum_name',
|
||||
'default_theme',
|
||||
'accent_color_dark',
|
||||
'accent_color_light',
|
||||
'logo_dark',
|
||||
'logo_light',
|
||||
'show_header_name',
|
||||
'favicon_ico',
|
||||
'favicon_16',
|
||||
'favicon_32',
|
||||
'favicon_48',
|
||||
'favicon_64',
|
||||
'favicon_128',
|
||||
'favicon_256',
|
||||
]
|
||||
const results = await Promise.all(keys.map((key) => fetchSetting(key)))
|
||||
if (!active) return
|
||||
const next = {
|
||||
forumName: results[0]?.value || '',
|
||||
defaultTheme: results[1]?.value || 'auto',
|
||||
darkAccent: results[2]?.value || '',
|
||||
lightAccent: results[3]?.value || '',
|
||||
darkLogo: results[4]?.value || '',
|
||||
lightLogo: results[5]?.value || '',
|
||||
showHeaderName: results[6]?.value !== 'false',
|
||||
faviconIco: results[7]?.value || '',
|
||||
favicon16: results[8]?.value || '',
|
||||
favicon32: results[9]?.value || '',
|
||||
favicon48: results[10]?.value || '',
|
||||
favicon64: results[11]?.value || '',
|
||||
favicon128: results[12]?.value || '',
|
||||
favicon256: results[13]?.value || '',
|
||||
}
|
||||
setGeneralSettings(next)
|
||||
} catch (err) {
|
||||
if (active) setGeneralError(err.message)
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const handleGeneralSave = async (event) => {
|
||||
event.preventDefault()
|
||||
setGeneralSaving(true)
|
||||
setGeneralError('')
|
||||
try {
|
||||
await Promise.all([
|
||||
saveSetting('forum_name', generalSettings.forumName.trim() || ''),
|
||||
saveSetting('default_theme', generalSettings.defaultTheme || 'auto'),
|
||||
saveSetting('accent_color_dark', generalSettings.darkAccent.trim() || ''),
|
||||
saveSetting('accent_color_light', generalSettings.lightAccent.trim() || ''),
|
||||
saveSetting('logo_dark', generalSettings.darkLogo.trim() || ''),
|
||||
saveSetting('logo_light', generalSettings.lightLogo.trim() || ''),
|
||||
saveSetting('show_header_name', generalSettings.showHeaderName ? 'true' : 'false'),
|
||||
saveSetting('favicon_ico', generalSettings.faviconIco.trim() || ''),
|
||||
saveSetting('favicon_16', generalSettings.favicon16.trim() || ''),
|
||||
saveSetting('favicon_32', generalSettings.favicon32.trim() || ''),
|
||||
saveSetting('favicon_48', generalSettings.favicon48.trim() || ''),
|
||||
saveSetting('favicon_64', generalSettings.favicon64.trim() || ''),
|
||||
saveSetting('favicon_128', generalSettings.favicon128.trim() || ''),
|
||||
saveSetting('favicon_256', generalSettings.favicon256.trim() || ''),
|
||||
])
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('speedbb-settings-updated', {
|
||||
detail: {
|
||||
forumName: generalSettings.forumName.trim() || '',
|
||||
defaultTheme: generalSettings.defaultTheme || 'auto',
|
||||
accentDark: generalSettings.darkAccent.trim() || '',
|
||||
accentLight: generalSettings.lightAccent.trim() || '',
|
||||
logoDark: generalSettings.darkLogo.trim() || '',
|
||||
logoLight: generalSettings.lightLogo.trim() || '',
|
||||
showHeaderName: generalSettings.showHeaderName,
|
||||
faviconIco: generalSettings.faviconIco.trim() || '',
|
||||
favicon16: generalSettings.favicon16.trim() || '',
|
||||
favicon32: generalSettings.favicon32.trim() || '',
|
||||
favicon48: generalSettings.favicon48.trim() || '',
|
||||
favicon64: generalSettings.favicon64.trim() || '',
|
||||
favicon128: generalSettings.favicon128.trim() || '',
|
||||
favicon256: generalSettings.favicon256.trim() || '',
|
||||
},
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
setGeneralError(err.message)
|
||||
} finally {
|
||||
setGeneralSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDefaultThemeChange = async (value) => {
|
||||
const previous = generalSettings.defaultTheme
|
||||
setGeneralSettings((prev) => ({ ...prev, defaultTheme: value }))
|
||||
setGeneralError('')
|
||||
try {
|
||||
await saveSetting('default_theme', value)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('speedbb-settings-updated', {
|
||||
detail: { defaultTheme: value },
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
setGeneralSettings((prev) => ({ ...prev, defaultTheme: previous }))
|
||||
setGeneralError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoUpload = async (file, variantKey) => {
|
||||
if (!file) return
|
||||
setGeneralUploading(true)
|
||||
setGeneralError('')
|
||||
try {
|
||||
const result = await uploadLogo(file)
|
||||
const url = result?.url || ''
|
||||
const settingKey = variantKey === 'darkLogo' ? 'logo_dark' : 'logo_light'
|
||||
setGeneralSettings((prev) => ({ ...prev, [variantKey]: url }))
|
||||
if (url) {
|
||||
await saveSetting(settingKey, url)
|
||||
}
|
||||
} catch (err) {
|
||||
setGeneralError(err.message)
|
||||
} finally {
|
||||
setGeneralUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFaviconUpload = async (file, settingKey, stateKey) => {
|
||||
if (!file) return
|
||||
setGeneralUploading(true)
|
||||
setGeneralError('')
|
||||
try {
|
||||
const result = await uploadFavicon(file)
|
||||
const url = result?.url || ''
|
||||
setGeneralSettings((prev) => ({ ...prev, [stateKey]: url }))
|
||||
if (url) {
|
||||
await saveSetting(settingKey, url)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('speedbb-settings-updated', {
|
||||
detail: { [stateKey]: url },
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
setGeneralError(err.message)
|
||||
} finally {
|
||||
setGeneralUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const faviconIcoDropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico', 'faviconIco'),
|
||||
})
|
||||
|
||||
const favicon16Dropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16', 'favicon16'),
|
||||
})
|
||||
|
||||
const favicon32Dropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32', 'favicon32'),
|
||||
})
|
||||
|
||||
const favicon48Dropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48', 'favicon48'),
|
||||
})
|
||||
|
||||
const favicon64Dropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64', 'favicon64'),
|
||||
})
|
||||
|
||||
const favicon128Dropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128', 'favicon128'),
|
||||
})
|
||||
|
||||
const favicon256Dropzone = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/x-icon': ['.ico'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256', 'favicon256'),
|
||||
})
|
||||
|
||||
const darkLogoDropzone = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleLogoUpload(files[0], 'darkLogo'),
|
||||
})
|
||||
|
||||
const lightLogoDropzone = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
onDrop: (files) => handleLogoUpload(files[0], 'lightLogo'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
|
||||
@@ -218,6 +486,7 @@ export default function Acp({ isAdmin }) {
|
||||
)
|
||||
}
|
||||
const [collapsed, setCollapsed] = useState(() => new Set())
|
||||
const hasInitializedCollapse = useRef(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -231,7 +500,7 @@ export default function Acp({ isAdmin }) {
|
||||
setError('')
|
||||
try {
|
||||
const data = await listAllForums()
|
||||
setForums(data)
|
||||
setForums(data.filter((forum) => !forum.deleted_at))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -245,6 +514,16 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitializedCollapse.current && forums.length > 0) {
|
||||
const ids = forums
|
||||
.filter((forum) => forum.type === 'category')
|
||||
.map((forum) => String(forum.id))
|
||||
setCollapsed(new Set(ids))
|
||||
hasInitializedCollapse.current = true
|
||||
}
|
||||
}, [forums])
|
||||
|
||||
const refreshUsers = async () => {
|
||||
setUsersLoading(true)
|
||||
setUsersError('')
|
||||
@@ -383,6 +662,18 @@ export default function Acp({ isAdmin }) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleStartCreateChild = (type, parentId) => {
|
||||
setSelectedId(null)
|
||||
setShowModal(true)
|
||||
setCreateType(type)
|
||||
setForm({
|
||||
name: '',
|
||||
description: '',
|
||||
type,
|
||||
parentId: parentId ? String(parentId) : '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
@@ -391,6 +682,10 @@ export default function Acp({ isAdmin }) {
|
||||
setError(t('acp.forums_name_required'))
|
||||
return
|
||||
}
|
||||
if (form.type === 'forum' && !form.parentId) {
|
||||
setError(t('acp.forums_parent_required'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (selectedId) {
|
||||
await updateForum(selectedId, {
|
||||
@@ -437,6 +732,11 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (pendingOrder.current) {
|
||||
const { parentId, ordered } = pendingOrder.current
|
||||
pendingOrder.current = null
|
||||
reorderForums(parentId, ordered).catch((err) => setError(err.message))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setOverId(null)
|
||||
}
|
||||
@@ -493,6 +793,7 @@ export default function Acp({ isAdmin }) {
|
||||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||||
setOverId(String(targetId))
|
||||
applyLocalOrder(parentId, ordered)
|
||||
pendingOrder.current = { parentId, ordered }
|
||||
}
|
||||
|
||||
const handleDragEnter = (forumId) => {
|
||||
@@ -539,6 +840,7 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
|
||||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||||
pendingOrder.current = null
|
||||
|
||||
try {
|
||||
await reorderForums(parentId, ordered)
|
||||
@@ -602,7 +904,7 @@ export default function Acp({ isAdmin }) {
|
||||
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<span
|
||||
className="bb-drag-handle text-muted"
|
||||
style={{ cursor: 'grab', display: 'inline-flex' }}
|
||||
@@ -611,6 +913,24 @@ export default function Acp({ isAdmin }) {
|
||||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||||
</span>
|
||||
<ButtonGroup size="sm" className="bb-action-group">
|
||||
{node.type === 'category' && (
|
||||
<>
|
||||
<Button
|
||||
variant="dark"
|
||||
onClick={() => handleStartCreateChild('category', node.id)}
|
||||
title={t('acp.add_category')}
|
||||
>
|
||||
<i className="bi bi-folder-plus" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="dark"
|
||||
onClick={() => handleStartCreateChild('forum', node.id)}
|
||||
title={t('acp.add_forum')}
|
||||
>
|
||||
<i className="bi bi-chat-left-text" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -642,6 +962,296 @@ export default function Acp({ isAdmin }) {
|
||||
<Tabs defaultActiveKey="general" className="mb-3">
|
||||
<Tab eventKey="general" title={t('acp.general')}>
|
||||
<p className="bb-muted">{t('acp.general_hint')}</p>
|
||||
{generalError && <p className="text-danger">{generalError}</p>}
|
||||
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
|
||||
<Row className="g-3">
|
||||
<Col lg={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.forum_name')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={generalSettings.forumName}
|
||||
onChange={(event) =>
|
||||
setGeneralSettings((prev) => ({ ...prev, forumName: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-2">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
id="acp-show-header-name"
|
||||
label={t('acp.show_header_name')}
|
||||
checked={generalSettings.showHeaderName}
|
||||
onChange={(event) =>
|
||||
setGeneralSettings((prev) => ({
|
||||
...prev,
|
||||
showHeaderName: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.default_theme')}</Form.Label>
|
||||
<Form.Select
|
||||
value={generalSettings.defaultTheme}
|
||||
onChange={(event) => handleDefaultThemeChange(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 lg={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.accent_dark')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={generalSettings.darkAccent}
|
||||
onChange={(event) =>
|
||||
setGeneralSettings((prev) => ({
|
||||
...prev,
|
||||
darkAccent: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="#f29b3f"
|
||||
/>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={generalSettings.darkAccent || '#f29b3f'}
|
||||
onChange={(event) =>
|
||||
setGeneralSettings((prev) => ({
|
||||
...prev,
|
||||
darkAccent: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.accent_light')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={generalSettings.lightAccent}
|
||||
onChange={(event) =>
|
||||
setGeneralSettings((prev) => ({
|
||||
...prev,
|
||||
lightAccent: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="#f29b3f"
|
||||
/>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={generalSettings.lightAccent || '#f29b3f'}
|
||||
onChange={(event) =>
|
||||
setGeneralSettings((prev) => ({
|
||||
...prev,
|
||||
lightAccent: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.logo_dark')}</Form.Label>
|
||||
<div
|
||||
{...darkLogoDropzone.getRootProps({
|
||||
className: 'bb-dropzone',
|
||||
})}
|
||||
>
|
||||
<input {...darkLogoDropzone.getInputProps()} />
|
||||
{generalSettings.darkLogo ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.darkLogo} alt={t('acp.logo_dark')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.logo_light')}</Form.Label>
|
||||
<div
|
||||
{...lightLogoDropzone.getRootProps({
|
||||
className: 'bb-dropzone',
|
||||
})}
|
||||
>
|
||||
<input {...lightLogoDropzone.getInputProps()} />
|
||||
{generalSettings.lightLogo ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.lightLogo} alt={t('acp.logo_light')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Accordion className="bb-acp-accordion">
|
||||
<Accordion.Item eventKey="favicons">
|
||||
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
|
||||
<Accordion.Body>
|
||||
<Row className="g-3">
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
|
||||
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...faviconIcoDropzone.getInputProps()} />
|
||||
{generalSettings.faviconIco ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.faviconIco} alt={t('acp.favicon_ico')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_16')}</Form.Label>
|
||||
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...favicon16Dropzone.getInputProps()} />
|
||||
{generalSettings.favicon16 ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.favicon16} alt={t('acp.favicon_16')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_32')}</Form.Label>
|
||||
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...favicon32Dropzone.getInputProps()} />
|
||||
{generalSettings.favicon32 ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.favicon32} alt={t('acp.favicon_32')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_48')}</Form.Label>
|
||||
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...favicon48Dropzone.getInputProps()} />
|
||||
{generalSettings.favicon48 ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.favicon48} alt={t('acp.favicon_48')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_64')}</Form.Label>
|
||||
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...favicon64Dropzone.getInputProps()} />
|
||||
{generalSettings.favicon64 ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.favicon64} alt={t('acp.favicon_64')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_128')}</Form.Label>
|
||||
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...favicon128Dropzone.getInputProps()} />
|
||||
{generalSettings.favicon128 ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.favicon128} alt={t('acp.favicon_128')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col lg={4}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('acp.favicon_256')}</Form.Label>
|
||||
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||
<input {...favicon256Dropzone.getInputProps()} />
|
||||
{generalSettings.favicon256 ? (
|
||||
<div className="bb-dropzone-preview">
|
||||
<img src={generalSettings.favicon256} alt={t('acp.favicon_256')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bb-dropzone-placeholder">
|
||||
<i className="bi bi-upload" aria-hidden="true" />
|
||||
<span>{t('acp.logo_upload')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</Accordion.Body>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Col>
|
||||
<Col xs={12} className="d-flex justify-content-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bb-accent-button"
|
||||
disabled={generalSaving || generalUploading}
|
||||
>
|
||||
{generalSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Tab>
|
||||
<Tab eventKey="forums" title={t('acp.forums')}>
|
||||
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
||||
@@ -651,11 +1261,11 @@ export default function Acp({ isAdmin }) {
|
||||
<div className="d-flex align-items-center justify-content-between mb-3 gap-3 flex-wrap">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<h5 className="mb-0">{t('acp.forums_tree')}</h5>
|
||||
<Button size="sm" variant="outline-dark" onClick={handleExpandAll}>
|
||||
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleExpandAll}>
|
||||
<i className="bi bi-arrows-expand me-1" aria-hidden="true" />
|
||||
{t('acp.expand_all')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline-dark" onClick={handleCollapseAll}>
|
||||
<Button size="sm" variant="outline-dark" className="bb-acp-action" onClick={handleCollapseAll}>
|
||||
<i className="bi bi-arrows-collapse me-1" aria-hidden="true" />
|
||||
{t('acp.collapse_all')}
|
||||
</Button>
|
||||
@@ -663,7 +1273,8 @@ export default function Acp({ isAdmin }) {
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={createType === 'category' ? 'dark' : 'outline-dark'}
|
||||
variant="outline-dark"
|
||||
className={`bb-acp-action ${createType === 'category' ? 'bb-acp-action--active' : ''}`}
|
||||
onClick={() => handleStartCreate('category')}
|
||||
>
|
||||
<i className="bi bi-folder2 me-1" aria-hidden="true" />
|
||||
@@ -671,7 +1282,8 @@ export default function Acp({ isAdmin }) {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={createType === 'forum' ? 'dark' : 'outline-dark'}
|
||||
variant="outline-dark"
|
||||
className={`bb-acp-action ${createType === 'forum' ? 'bb-acp-action--active' : ''}`}
|
||||
onClick={() => handleStartCreate('forum')}
|
||||
>
|
||||
<i className="bi bi-chat-left-text me-1" aria-hidden="true" />
|
||||
@@ -720,11 +1332,29 @@ export default function Acp({ isAdmin }) {
|
||||
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
||||
<Modal.Header closeButton closeVariant="white">
|
||||
<Modal.Title>
|
||||
{selectedId ? t('acp.forums_edit_title') : t('acp.forums_create_title')}
|
||||
{selectedId
|
||||
? form.type === 'category'
|
||||
? t('acp.forums_edit_category_title')
|
||||
: t('acp.forums_edit_forum_title')
|
||||
: createType === 'category'
|
||||
? t('acp.forums_create_category_title')
|
||||
: createType === 'forum'
|
||||
? t('acp.forums_create_forum_title')
|
||||
: t('acp.forums_create_title')}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="bb-muted">{t('acp.forums_form_hint')}</p>
|
||||
<p className="bb-muted">
|
||||
{selectedId
|
||||
? form.type === 'category'
|
||||
? t('acp.forums_edit_category_hint')
|
||||
: t('acp.forums_edit_forum_hint')
|
||||
: createType === 'category'
|
||||
? t('acp.forums_create_category_hint')
|
||||
: createType === 'forum'
|
||||
? t('acp.forums_create_forum_hint')
|
||||
: t('acp.forums_form_hint')}
|
||||
</p>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.title')}</Form.Label>
|
||||
@@ -744,23 +1374,27 @@ export default function Acp({ isAdmin }) {
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('acp.forums_type')}</Form.Label>
|
||||
<Form.Select
|
||||
value={form.type}
|
||||
onChange={(event) => setForm({ ...form, type: event.target.value })}
|
||||
>
|
||||
<option value="category">{t('forum.type_category')}</option>
|
||||
<option value="forum">{t('forum.type_forum')}</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
{selectedId && (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('acp.forums_type')}</Form.Label>
|
||||
<Form.Select
|
||||
value={form.type}
|
||||
onChange={(event) => setForm({ ...form, type: event.target.value })}
|
||||
>
|
||||
<option value="category">{t('forum.type_category')}</option>
|
||||
<option value="forum">{t('forum.type_forum')}</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
)}
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('acp.forums_parent')}</Form.Label>
|
||||
<Form.Select
|
||||
value={form.parentId}
|
||||
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
|
||||
>
|
||||
<option value="">{t('acp.forums_parent_root')}</option>
|
||||
<option value="" disabled={form.type === 'forum'}>
|
||||
{t('acp.forums_parent_root')}
|
||||
</option>
|
||||
{categoryOptions
|
||||
.filter((option) => String(option.id) !== String(selectedId))
|
||||
.map((option) => (
|
||||
|
||||
212
resources/js/pages/BoardIndex.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
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 || t('forum.no_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">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--last">
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
return (
|
||||
<Container 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,5 +1,5 @@
|
||||
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 { useAuth } from '../context/AuthContext'
|
||||
@@ -13,11 +13,36 @@ export default function ForumView() {
|
||||
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()
|
||||
|
||||
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 || t('forum.no_description')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-board-cell bb-board-cell--topics">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
||||
<div className="bb-board-cell bb-board-cell--last">
|
||||
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
@@ -62,6 +87,7 @@ export default function ForumView() {
|
||||
setBody('')
|
||||
const updated = await listThreadsByForum(id)
|
||||
setThreads(updated)
|
||||
setShowModal(false)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -75,106 +101,162 @@ export default function ForumView() {
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
{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' && (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
<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-topic-table">
|
||||
<div className="bb-topic-header">
|
||||
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
|
||||
</div>
|
||||
{threads.length === 0 && (
|
||||
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<div className="bb-topic-row" key={thread.id}>
|
||||
<div className="bb-topic-cell bb-topic-cell--title">
|
||||
<div className="bb-topic-title">
|
||||
<span className="bb-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left" />
|
||||
</span>
|
||||
<div className="bb-topic-text">
|
||||
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
|
||||
<div className="bb-topic-meta">
|
||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||
<span>{t('thread.by')}</span>
|
||||
<span className="bb-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-topic-date">
|
||||
{thread.created_at.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
||||
<div className="bb-topic-cell bb-topic-cell--last">
|
||||
<div className="bb-topic-last">
|
||||
<span className="bb-topic-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
<span className="bb-topic-author">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</span>
|
||||
</span>
|
||||
{thread.created_at && (
|
||||
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
{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,20 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { Badge, Container } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listAllForums } from '../api/client'
|
||||
import { listAllForums, listThreads } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Home() {
|
||||
const [forums, setForums] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingForums, setLoadingForums] = useState(true)
|
||||
const [loadingThreads, setLoadingThreads] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listAllForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
.finally(() => setLoadingForums(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoadingThreads(false))
|
||||
}, [])
|
||||
|
||||
const getParentId = (forum) => {
|
||||
@@ -56,6 +65,33 @@ export default function Home() {
|
||||
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 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}>
|
||||
@@ -82,22 +118,111 @@ export default function Home() {
|
||||
))
|
||||
|
||||
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>
|
||||
<Container 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>{threads.length}</strong>
|
||||
</div>
|
||||
<div className="bb-portal-stat">
|
||||
<span>{t('portal.stat_forums')}</span>
|
||||
<strong>{forums.length}</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>}
|
||||
<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) => (
|
||||
<div className="bb-portal-topic-row" key={thread.id}>
|
||||
<div className="bb-portal-topic-main">
|
||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
||||
<i className="bi bi-chat-left-text" />
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
<Badge bg="secondary">
|
||||
{thread.user_name || t('thread.anonymous')}
|
||||
</Badge>
|
||||
<span className="bb-portal-topic-forum">
|
||||
{t('portal.forum_label')}{' '}
|
||||
{resolveForumId(thread) ? (
|
||||
<Link
|
||||
to={`/forum/${resolveForumId(thread)}`}
|
||||
className="bb-portal-topic-forum-link"
|
||||
>
|
||||
{resolveForumName(thread)}
|
||||
</Link>
|
||||
) : (
|
||||
resolveForumName(thread)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">0</div>
|
||||
<div className="bb-portal-topic-cell">—</div>
|
||||
<div className="bb-portal-topic-cell">
|
||||
{thread.created_at?.slice(0, 10) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<div className="bb-portal-user-avatar" />
|
||||
<div className="bb-portal-user-name">tracer</div>
|
||||
<div className="bb-portal-user-role">Operator</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>
|
||||
)
|
||||
}
|
||||
|
||||
71
resources/js/pages/Ucp.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Container, Form, Row, Col } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const accentMode = accentOverride ? 'custom' : 'system'
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const locale = event.target.value
|
||||
i18n.changeLanguage(locale)
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5 bb-portal-shell">
|
||||
<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,41 @@
|
||||
"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.forums_parent_root": "Wurzel (kein Parent)",
|
||||
"acp.forums_tree": "Forenbaum",
|
||||
"acp.forums_type": "Typ",
|
||||
@@ -31,7 +58,6 @@
|
||||
"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.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
||||
@@ -51,6 +77,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",
|
||||
@@ -63,7 +90,9 @@
|
||||
"forum.no_description": "Noch keine Beschreibung vorhanden.",
|
||||
"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",
|
||||
@@ -82,16 +111,49 @@
|
||||
"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_forums": "Foren",
|
||||
"portal.latest_posts": "Aktuelle Beiträge",
|
||||
"portal.empty_posts": "Noch keine Beiträge.",
|
||||
"portal.topic": "Themen",
|
||||
"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.advertisement": "Werbung",
|
||||
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
||||
"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:",
|
||||
@@ -100,6 +162,10 @@
|
||||
"thread.loading": "Thread wird geladen...",
|
||||
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
||||
"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,41 @@
|
||||
"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.forums_parent_root": "Root (no parent)",
|
||||
"acp.forums_tree": "Forum tree",
|
||||
"acp.forums_type": "Type",
|
||||
@@ -31,7 +58,6 @@
|
||||
"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.register_hint": "Register with an email and a unique username.",
|
||||
@@ -51,6 +77,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",
|
||||
@@ -63,7 +90,9 @@
|
||||
"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",
|
||||
@@ -82,16 +111,49 @@
|
||||
"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_forums": "Forums",
|
||||
"portal.latest_posts": "Latest posts",
|
||||
"portal.empty_posts": "No posts yet.",
|
||||
"portal.topic": "Topics",
|
||||
"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.advertisement": "Advertisement",
|
||||
"ucp.intro": "Manage your basic preferences for the forum.",
|
||||
"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:",
|
||||
@@ -100,6 +162,10 @@
|
||||
"thread.loading": "Loading thread...",
|
||||
"thread.login_hint": "Log in to reply to this thread.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\I18nController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\SettingController;
|
||||
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 Illuminate\Support\Facades\Route;
|
||||
@@ -16,6 +18,11 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
|
||||
|
||||
Route::get('/version', VersionController::class);
|
||||
Route::get('/settings', [SettingController::class, 'index']);
|
||||
Route::post('/settings', [SettingController::class, 'store'])->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::get('/i18n/{locale}', I18nController::class);
|
||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 389 B |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 10 KiB |