addMinutes(5), function () { return ForumCategory::query() ->select(['id', 'name', 'slug', 'parent_id', 'position']) ->roots() ->ordered() ->withForumStats() ->get() ->map(function (ForumCategory $category) { return [ 'id' => $category->id, 'name' => $category->name, 'slug' => $category->slug, 'thread_count' => (int) ($category->thread_count ?? 0), 'post_count' => (int) ($category->post_count ?? 0), 'last_activity_at' => $category->lastThread?->last_post_at ?? $category->lastThread?->updated_at, 'preview_image' => $category->preview_image, ]; }); }); $data = [ 'categories' => $categories, 'page_title' => 'Forum', 'page_meta_description' => 'Skinbase forum discussions.', 'page_meta_keywords' => 'forum, discussions, topics, skinbase', ]; return view('forum.index', $data); } public function showCategory(Request $request, ForumCategory $category) { $subtopics = ForumThread::query() ->where('category_id', $category->id) ->withCount('posts') ->with('user:id,name') ->orderByDesc('is_pinned') ->orderByDesc('last_post_at') ->orderByDesc('id') ->paginate(50) ->withQueryString(); $subtopics->getCollection()->transform(function (ForumThread $item) { return (object) [ 'topic_id' => $item->id, 'topic' => $item->title, 'discuss' => $item->content, 'post_date' => $item->created_at, 'last_update' => $item->last_post_at ?? $item->created_at, 'uname' => $item->user?->name, 'num_posts' => (int) ($item->posts_count ?? 0), ]; }); $topic = (object) [ 'topic_id' => $category->id, 'topic' => $category->name, 'discuss' => null, ]; return view('forum.community.topic', [ 'type' => 'subtopics', 'topic' => $topic, 'subtopics' => $subtopics, 'category' => $category, 'page_title' => $category->name, 'page_meta_description' => 'Forum section: ' . $category->name, 'page_meta_keywords' => 'forum, section, skinbase', ]); } public function showThread(Request $request, ForumThread $thread, ?string $slug = null) { if (! empty($thread->slug) && $slug !== $thread->slug) { return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug], 301); } $thread->loadMissing([ 'category:id,name,slug', 'user:id,name', 'user.profile:user_id,avatar_hash', ]); $threadMeta = Cache::remember( 'forum:thread:meta:v1:' . $thread->id . ':' . ($thread->updated_at?->timestamp ?? 0), now()->addMinutes(5), fn () => [ 'category' => $thread->category, 'author' => $thread->user, ] ); $sort = strtolower((string) $request->query('sort', 'asc')) === 'desc' ? 'desc' : 'asc'; $opPost = ForumPost::query() ->where('thread_id', $thread->id) ->with([ 'user:id,name', 'user.profile:user_id,avatar_hash', 'attachments:id,post_id,file_path,file_size,mime_type,width,height', ]) ->orderBy('created_at', 'asc') ->orderBy('id', 'asc') ->first(); $posts = ForumPost::query() ->where('thread_id', $thread->id) ->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id)) ->with([ 'user:id,name', 'user.profile:user_id,avatar_hash', 'attachments:id,post_id,file_path,file_size,mime_type,width,height', ]) ->orderBy('created_at', $sort) ->paginate(50) ->withQueryString(); $replyCount = max((int) ForumPost::query()->where('thread_id', $thread->id)->count() - 1, 0); $attachments = collect($opPost?->attachments ?? []) ->merge($posts->getCollection()->flatMap(fn (ForumPost $post) => $post->attachments ?? [])) ->values(); $quotedPost = null; $quotePostId = (int) $request->query('quote', 0); if ($quotePostId > 0) { $quotedPost = ForumPost::query() ->where('thread_id', $thread->id) ->with('user:id,name') ->find($quotePostId); } $replyPrefill = old('content'); if ($replyPrefill === null && $quotedPost) { $quotedAuthor = (string) ($quotedPost->user?->name ?? 'Anonymous'); $quoteText = trim(strip_tags((string) $quotedPost->content)); $quoteText = preg_replace('/\s+/', ' ', $quoteText) ?? $quoteText; $quoteSnippet = Str::limit($quoteText, 300); $replyPrefill = '[quote=' . $quotedAuthor . ']' . $quoteSnippet . '[/quote]' . "\n\n"; } return view('forum.thread.show', [ 'thread' => $thread, 'category' => $threadMeta['category'] ?? $thread->category, 'author' => $threadMeta['author'] ?? $thread->user, 'opPost' => $opPost, 'posts' => $posts, 'attachments' => $attachments, 'reply_count' => $replyCount, 'quoted_post' => $quotedPost, 'reply_prefill' => $replyPrefill, 'sort' => $sort, 'page_title' => $thread->title, 'page_meta_description' => 'Forum thread: ' . $thread->title, 'page_meta_keywords' => 'forum, thread, skinbase', ]); } public function createThreadForm(ForumCategory $category) { return view('forum.community.new-thread', [ 'category' => $category, 'page_title' => 'New thread', ]); } public function storeThread(Request $request, ForumCategory $category) { $user = Auth::user(); abort_unless($user, 403); $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'content' => ['required', 'string', 'min:2'], ]); $baseSlug = Str::slug((string) $validated['title']); $slug = $baseSlug ?: ('thread-' . time()); $counter = 2; while (ForumThread::where('slug', $slug)->exists()) { $slug = ($baseSlug ?: 'thread') . '-' . $counter; $counter++; } $thread = ForumThread::create([ 'category_id' => $category->id, 'user_id' => (int) $user->id, 'title' => $validated['title'], 'slug' => $slug, 'content' => $validated['content'], 'views' => 0, 'is_locked' => false, 'is_pinned' => false, 'visibility' => 'public', 'last_post_at' => now(), ]); ForumPost::create([ 'thread_id' => $thread->id, 'user_id' => (int) $user->id, 'content' => $validated['content'], 'is_edited' => false, 'edited_at' => null, ]); return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]); } public function reply(Request $request, ForumThread $thread) { $user = Auth::user(); abort_unless($user, 403); abort_if($thread->is_locked, 423, 'Thread is locked.'); $validated = $request->validate([ 'content' => ['required', 'string', 'min:2'], ]); ForumPost::create([ 'thread_id' => $thread->id, 'user_id' => (int) $user->id, 'content' => $validated['content'], 'is_edited' => false, 'edited_at' => null, ]); $thread->last_post_at = now(); $thread->save(); return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]); } public function editPostForm(ForumPost $post) { $user = Auth::user(); abort_unless($user, 403); abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403); return view('forum.community.edit-post', [ 'post' => $post, 'thread' => $post->thread, 'page_title' => 'Edit post', ]); } public function updatePost(Request $request, ForumPost $post) { $user = Auth::user(); abort_unless($user, 403); abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403); $validated = $request->validate([ 'content' => ['required', 'string', 'min:2'], ]); $post->content = $validated['content']; $post->is_edited = true; $post->edited_at = now(); $post->save(); return redirect()->route('forum.thread.show', ['thread' => $post->thread_id, 'slug' => $post->thread?->slug]); } public function reportPost(Request $request, ForumPost $post) { $user = Auth::user(); abort_unless($user, 403); abort_if((int) $post->user_id === (int) $user->id, 422, 'You cannot report your own post.'); $validated = $request->validate([ 'reason' => ['nullable', 'string', 'max:500'], ]); ForumPostReport::query()->updateOrCreate( [ 'post_id' => (int) $post->id, 'reporter_user_id' => (int) $user->id, ], [ 'thread_id' => (int) $post->thread_id, 'reason' => $validated['reason'] ?? null, 'status' => 'open', 'source_url' => (string) $request->headers->get('referer', ''), 'reported_at' => now(), ] ); return back()->with('status', 'Post reported. Thank you for helping moderate the forum.'); } public function lockThread(ForumThread $thread) { $thread->is_locked = true; $thread->save(); return back(); } public function unlockThread(ForumThread $thread) { $thread->is_locked = false; $thread->save(); return back(); } public function pinThread(ForumThread $thread) { $thread->is_pinned = true; $thread->save(); return back(); } public function unpinThread(ForumThread $thread) { $thread->is_pinned = false; $thread->save(); return back(); } }