Files
SkinbaseNova/app/Http/Controllers/Forum/ForumController.php
Gregor Klevze dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00

350 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers\Forum;
use App\Http\Controllers\Controller;
use App\Models\ForumCategory;
use App\Models\ForumPost;
use App\Models\ForumPostReport;
use App\Models\ForumThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
class ForumController extends Controller
{
public function index()
{
$categories = Cache::remember('forum:index:categories:v1', now()->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),
'is_pinned' => (bool) $item->is_pinned,
];
});
$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();
}
}