Add news article comments and reactions

This commit is contained in:
2026-05-01 11:43:49 +02:00
parent 874f8feb9c
commit 28e7e46e13
22 changed files with 20083 additions and 26 deletions

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Services\News\NewsArticleCommentService;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentController extends Controller
{
private const MAX_LENGTH = 4_000;
public function __construct(private readonly NewsArticleCommentService $comments)
{
}
public function index(Request $request, int $articleId): JsonResponse
{
$article = $this->resolveArticle($articleId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$comments = NewsArticleComment::query()
->with(['user.profile', 'visibleReplies'])
->where('article_id', $article->id)
->where('status', 'visible')
->whereNull('parent_id')
->orderByDesc('created_at')
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);
$viewer = $request->user();
$items = $comments->getCollection()->map(
fn (NewsArticleComment $comment): array => $this->formatComment($comment, $viewer, $article, true)
);
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
public function store(Request $request, int $articleId): JsonResponse
{
$article = $this->resolveArticle($articleId);
$data = $request->validate([
'content' => ['required', 'string', 'min:2', 'max:' . self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:news_article_comments,id'],
]);
$parent = null;
if (! empty($data['parent_id'])) {
$parent = NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->find((int) $data['parent_id']);
if (! $parent) {
return response()->json([
'errors' => [
'content' => ['The comment you are replying to is no longer available.'],
],
], 422);
}
}
$comment = $this->comments->create($article, $request->user(), (string) $data['content'], $parent);
return response()->json([
'data' => $this->formatComment($comment, $request->user(), $article, false),
], 201);
}
public function destroy(Request $request, int $articleId, int $commentId): JsonResponse
{
$article = $this->resolveArticle($articleId);
$comment = NewsArticleComment::query()
->where('article_id', $article->id)
->findOrFail($commentId);
$this->comments->delete($comment, $request->user());
return response()->json([
'message' => 'Comment deleted.',
]);
}
private function resolveArticle(int $articleId): NewsArticle
{
return NewsArticle::query()
->published()
->whereKey($articleId)
->firstOrFail();
}
private function formatComment(NewsArticleComment $comment, ?User $viewer, NewsArticle $article, bool $includeReplies = false): array
{
$user = $comment->user;
$displayName = $user?->username ?? $user?->name ?? $comment->author_name ?? 'Former member';
$avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [
'id' => $comment->id,
'parent_id' => $comment->parent_id,
'raw_content' => (string) ($comment->body ?? ''),
'rendered_content' => $comment->getDisplayHtml(),
'created_at' => $comment->created_at?->toIso8601String(),
'time_ago' => $comment->created_at ? Carbon::parse($comment->created_at)->diffForHumans() : null,
'can_delete' => $this->canDelete($comment, $article, $viewer),
'user' => [
'id' => $user?->id,
'username' => $user?->username,
'display' => $displayName,
'profile_url' => $user?->username ? '/@' . $user->username : null,
'avatar_url' => $user ? AvatarUrl::forUser((int) $user->id, $avatarHash, 64) : null,
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'replies' => [],
];
if ($includeReplies) {
$replies = $comment->relationLoaded('visibleReplies')
? $comment->visibleReplies
: collect();
$data['replies'] = $replies
->map(fn (NewsArticleComment $reply): array => $this->formatComment($reply, $viewer, $article, true))
->values()
->all();
}
return $data;
}
private function canDelete(NewsArticleComment $comment, NewsArticle $article, ?User $viewer): bool
{
if (! $viewer) {
return false;
}
return (int) $comment->user_id === (int) $viewer->id
|| (int) $article->author_id === (int) $viewer->id
|| $viewer->isAdmin()
|| $viewer->isModerator();
}
}

View File

@@ -7,6 +7,8 @@ use App\Http\Controllers\Controller;
use App\Models\ArtworkComment;
use App\Models\ArtworkReaction;
use App\Models\CommentReaction;
use App\Models\NewsArticleComment;
use App\Models\NewsArticleCommentReaction;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -77,6 +79,33 @@ class ReactionController extends Controller
);
}
public function newsCommentReactions(Request $request, int $commentId): JsonResponse
{
$this->resolveVisibleNewsComment($commentId);
return response()->json([
'entity_type' => 'news-comment',
'entity_id' => $commentId,
'totals' => $this->getTotals('news_article_comment_reactions', ['comment_id' => $commentId], $request->user()?->id),
]);
}
public function toggleNewsCommentReaction(Request $request, int $commentId): JsonResponse
{
$this->resolveVisibleNewsComment($commentId);
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new NewsArticleCommentReaction(),
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['comment_id' => $commentId],
entityId: $commentId,
entityType: 'news-comment',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Shared internals
// ─────────────────────────────────────────────────────────────────────────
@@ -189,4 +218,13 @@ class ReactionController extends Controller
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
}
}
private function resolveVisibleNewsComment(int $commentId): NewsArticleComment
{
return NewsArticleComment::query()
->where('id', $commentId)
->where('status', 'visible')
->whereHas('article', fn ($query) => $query->published())
->firstOrFail();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Services\News\NewsArticleCommentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentController extends Controller
{
public function __construct(private readonly NewsArticleCommentService $comments)
{
}
public function store(Request $request, string $slug): RedirectResponse
{
$article = $this->resolveArticle($slug);
$data = $request->validate([
'body' => ['required', 'string', 'min:2', 'max:4000'],
]);
$this->comments->create($article, $request->user(), (string) $data['body']);
return redirect()->to(route('news.show', ['slug' => $article->slug]) . '#comments')->with('status', 'Comment posted.');
}
public function destroy(Request $request, string $slug, NewsArticleComment $comment): RedirectResponse
{
$article = $this->resolveArticle($slug);
abort_unless((int) $comment->article_id === (int) $article->id, 404);
$this->comments->delete($comment, $request->user());
return redirect()->to(route('news.show', ['slug' => $article->slug]) . '#comments')->with('status', 'Comment removed.');
}
private function resolveArticle(string $slug): NewsArticle
{
return NewsArticle::query()
->published()
->where('slug', $slug)
->firstOrFail();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Services\News\NewsService;
use Illuminate\Http\Request;
@@ -155,10 +156,31 @@ class NewsController extends Controller
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
return view('news.show', [
'article' => $article,
'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => $comments,
'commentsCount' => $commentsCount,
] + $this->sidebarData());
}

View File

@@ -56,6 +56,9 @@ final class StudioNewsController extends Controller
'tagOptions' => $this->news->tagOptions(),
'relationTypeOptions' => $this->news->relationTypeOptions(),
'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'),
@@ -85,7 +88,11 @@ final class StudioNewsController extends Controller
'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(),
'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
'previewUrl' => route('studio.news.preview', ['article' => $article->id]),
'publishUrl' => route('studio.news.publish', ['article' => $article->id]),
'archiveUrl' => route('studio.news.archive', ['article' => $article->id]),
@@ -115,6 +122,8 @@ final class StudioNewsController extends Controller
'article' => $article,
'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => collect(),
'commentsCount' => 0,
'previewMode' => true,
'previewCanonical' => route('studio.news.preview', ['article' => $article->id]),
'previewBackUrl' => route('studio.news.edit', ['article' => $article->id]),
@@ -127,7 +136,16 @@ final class StudioNewsController extends Controller
$this->news->updateArticle($article, $request->user(), $this->validateArticle($request, $article));
return back()->with('success', 'Article updated.');
return redirect()->route('studio.news.edit', ['article' => $article->id])->with('success', 'Article updated.');
}
public function destroy(Request $request, NewsArticle $article): RedirectResponse
{
$this->authorizeNews($request);
$this->news->deleteArticle($article);
return redirect()->route('studio.news.index')->with('success', 'Article moved to trash.');
}
public function publish(Request $request, NewsArticle $article): RedirectResponse
@@ -331,7 +349,7 @@ final class StudioNewsController extends Controller
'title' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:800'],
'content' => ['required', 'string', 'max:50000'],
'content' => ['required', 'string', 'max:500000'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'],
@@ -340,12 +358,24 @@ final class StudioNewsController extends Controller
'published_at' => ['nullable', 'date'],
'is_featured' => ['nullable', 'boolean'],
'is_pinned' => ['nullable', 'boolean'],
'comments_enabled' => ['nullable', 'boolean'],
'tag_ids' => ['nullable', 'array'],
'tag_ids.*' => ['integer', 'exists:news_tags,id'],
'new_tag_names' => ['nullable', 'array', 'max:12'],
'new_tag_names.*' => ['string', 'max:80'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'meta_keywords' => ['nullable', 'string', 'max:255'],
'canonical_url' => ['nullable', 'url', 'max:2048'],
'canonical_url' => ['nullable', 'string', 'max:2048', function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === '' || $value === null) {
return;
}
$isAbsolute = filter_var($value, FILTER_VALIDATE_URL) !== false;
$isRelative = str_starts_with($value, '/');
if (! $isAbsolute && ! $isRelative) {
$fail('The canonical URL must be a valid URL or a relative path starting with /.');
}
}],
'og_title' => ['nullable', 'string', 'max:255'],
'og_description' => ['nullable', 'string', 'max:300'],
'og_image' => ['nullable', 'string', 'max:2048'],