diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6c644..48251c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,3 +39,17 @@ - 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. diff --git a/app/Http/Controllers/ForumController.php b/app/Http/Controllers/ForumController.php index bf66e8f..9c5dfd9 100644 --- a/app/Http/Controllers/ForumController.php +++ b/app/Http/Controllers/ForumController.php @@ -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); diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 07e0746..093e0ab 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -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); diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index d8876be..bff746d 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -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(), ]; diff --git a/app/Http/Controllers/UserSettingController.php b/app/Http/Controllers/UserSettingController.php new file mode 100644 index 0000000..0e9b44f --- /dev/null +++ b/app/Http/Controllers/UserSettingController.php @@ -0,0 +1,47 @@ +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, + ]); + } +} diff --git a/app/Models/Forum.php b/app/Models/Forum.php index e365211..c4135d7 100644 --- a/app/Models/Forum.php +++ b/app/Models/Forum.php @@ -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', diff --git a/app/Models/Post.php b/app/Models/Post.php index 35d6a26..f9f34d5 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -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', diff --git a/app/Models/Thread.php b/app/Models/Thread.php index 98ecf13..90d1048 100644 --- a/app/Models/Thread.php +++ b/app/Models/Thread.php @@ -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', diff --git a/app/Models/UserSetting.php b/app/Models/UserSetting.php new file mode 100644 index 0000000..9eb76a7 --- /dev/null +++ b/app/Models/UserSetting.php @@ -0,0 +1,18 @@ + 'array', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..ffd0c8c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } } diff --git a/database/migrations/2025_12_30_000000_add_soft_deletes_to_forums_threads_posts.php b/database/migrations/2025_12_30_000000_add_soft_deletes_to_forums_threads_posts.php new file mode 100644 index 0000000..097fc8b --- /dev/null +++ b/database/migrations/2025_12_30_000000_add_soft_deletes_to_forums_threads_posts.php @@ -0,0 +1,50 @@ +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(); + }); + } +}; diff --git a/database/migrations/2025_12_30_000200_enforce_forum_parent.php b/database/migrations/2025_12_30_000200_enforce_forum_parent.php new file mode 100644 index 0000000..0ef52d6 --- /dev/null +++ b/database/migrations/2025_12_30_000200_enforce_forum_parent.php @@ -0,0 +1,52 @@ +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'); + } + } +}; diff --git a/database/migrations/2025_12_30_000500_create_user_settings_table.php b/database/migrations/2025_12_30_000500_create_user_settings_table.php new file mode 100644 index 0000000..04c51f2 --- /dev/null +++ b/database/migrations/2025_12_30_000500_create_user_settings_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d76331a..c518525 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder RoleSeeder::class, UserSeeder::class, ForumSeeder::class, + ThreadSeeder::class, ]); } } diff --git a/database/seeders/ThreadSeeder.php b/database/seeders/ThreadSeeder.php new file mode 100644 index 0000000..2560774 --- /dev/null +++ b/database/seeders/ThreadSeeder.php @@ -0,0 +1,36 @@ +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), + ]); + } + } + } +} diff --git a/resources/js/App.jsx b/resources/js/App.jsx index e1755cd..5b5c1ab 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -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,197 @@ 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 }) { + 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 ( - - - - {t('app.brand')} - - {isAdmin && ( - - )} - - -