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,186 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ExportLegacyNewsCommentsSqlCommand extends Command
{
protected $signature = 'news:comments-export-legacy-sql
{--path=database/sql/news_article_comments_legacy_import.sql : Output SQL file path}
{--skip-empty : Skip comments with empty or whitespace-only content}
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
protected $description = 'Generate a production-safe SQL file for legacy news comments import';
public function handle(): int
{
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $exception) {
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
return self::FAILURE;
}
$legacyTable = $this->resolveLegacyTable();
if ($legacyTable === null) {
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
return self::FAILURE;
}
$outputPath = $this->resolveOutputPath((string) $this->option('path'));
$skipEmpty = (bool) $this->option('skip-empty');
$directory = dirname($outputPath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$handle = fopen($outputPath, 'wb');
if ($handle === false) {
$this->error('Unable to write SQL file: ' . $outputPath);
return self::FAILURE;
}
$written = 0;
$skippedEmpty = 0;
$legacyNewsIds = [];
fwrite($handle, "-- Legacy news comments import generated at " . now()->toDateTimeString() . PHP_EOL);
fwrite($handle, "START TRANSACTION;" . PHP_EOL . PHP_EOL);
DB::connection('legacy')
->table($legacyTable)
->orderBy('comment_id')
->chunk(500, function ($rows) use ($handle, $skipEmpty, &$written, &$skippedEmpty, &$legacyNewsIds): void {
foreach ($rows as $row) {
$legacyId = (int) ($row->comment_id ?? 0);
$legacyNewsId = (int) ($row->news_id ?? 0);
$legacyUserId = (int) ($row->user_id ?? 0);
$body = trim((string) ($row->message ?? ''));
if ($legacyId < 1 || $legacyNewsId < 1) {
continue;
}
if ($body === '') {
if ($skipEmpty) {
$skippedEmpty++;
continue;
}
$body = '[no content]';
}
$legacyNewsIds[$legacyNewsId] = $legacyNewsId;
$authorName = trim((string) ($row->author ?? ''));
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
$renderedBody = nl2br(e($body));
$userExpression = $legacyUserId > 0
? "CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = {$legacyUserId} AND users.deleted_at IS NULL) THEN {$legacyUserId} ELSE NULL END"
: 'NULL';
$statement = "INSERT IGNORE INTO news_article_comments (legacy_id, legacy_user_id, article_id, user_id, parent_id, author_name, body, rendered_body, status, legacy_posted_at, created_at, updated_at, deleted_at)\n"
. "SELECT {$legacyId}, " . ($legacyUserId > 0 ? (string) $legacyUserId : 'NULL') . ", news_articles.id, {$userExpression}, NULL, " . $this->quote($authorName !== '' ? $authorName : null) . ", " . $this->quote($body) . ", " . $this->quote($renderedBody) . ", 'visible', " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", NULL\n"
. "FROM news_articles\n"
. "WHERE news_articles.legacy_news_id = {$legacyNewsId}\n"
. "LIMIT 1;\n\n";
fwrite($handle, $statement);
$written++;
}
});
if ($legacyNewsIds !== []) {
foreach (array_chunk(array_values($legacyNewsIds), 250) as $chunk) {
fwrite($handle, 'UPDATE news_articles SET comments_enabled = 1 WHERE legacy_news_id IN (' . implode(', ', array_map('intval', $chunk)) . ');' . PHP_EOL);
}
fwrite($handle, PHP_EOL);
}
fwrite($handle, 'COMMIT;' . PHP_EOL);
fclose($handle);
$this->info('SQL export written to ' . $outputPath);
$this->table(
['Result', 'Count'],
[
['Statements written', $written],
['Skipped - empty body', $skippedEmpty],
['Articles enabled for comments', count($legacyNewsIds)],
]
);
return self::SUCCESS;
}
private function resolveLegacyTable(): ?string
{
$configured = trim((string) $this->option('table'));
if ($configured !== '') {
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
}
foreach (['news_comment', 'news_comments'] as $candidate) {
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
return $candidate;
}
}
return null;
}
private function resolveOutputPath(string $path): string
{
$trimmed = trim($path);
if ($trimmed === '') {
return base_path('database/sql/news_article_comments_legacy_import.sql');
}
if (preg_match('/^[A-Za-z]:\\\\|^\\\\\\\\|^\//', $trimmed) === 1) {
return $trimmed;
}
return base_path(str_replace(['/', '\\\\'], DIRECTORY_SEPARATOR, $trimmed));
}
private function normalizeTimestamp(mixed $value): string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return now()->toDateTimeString();
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable) {
return now()->toDateTimeString();
}
}
private function quote(?string $value): string
{
if ($value === null) {
return 'NULL';
}
$escaped = str_replace(
["\\", "\0", "\n", "\r", "\x1a", "'"],
["\\\\", "\\0", "\\n", "\\r", "\\Z", "\\'"],
$value,
);
return "'{$escaped}'";
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ImportLegacyNewsCommentsCommand extends Command
{
protected $signature = 'news:comments-import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}
{--skip-empty : Skip comments with empty or whitespace-only content}
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
protected $description = 'Import legacy news comments into news_article_comments';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipEmpty = (bool) $this->option('skip-empty');
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $exception) {
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
return self::FAILURE;
}
$legacyTable = $this->resolveLegacyTable();
if ($legacyTable === null) {
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasTable('news_article_comments')) {
$this->error('Target table `news_article_comments` is missing. Run migrations first.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasColumn('news_articles', 'legacy_news_id')) {
$this->error('Column `news_articles.legacy_news_id` is missing. Run migrations first.');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
$articleMap = DB::table('news_articles')
->whereNotNull('legacy_news_id')
->pluck('id', 'legacy_news_id')
->mapWithKeys(fn ($articleId, $legacyId): array => [(int) $legacyId => (int) $articleId])
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$alreadyImported = DB::table('news_article_comments')
->whereNotNull('legacy_id')
->pluck('legacy_id')
->flip()
->all();
$total = DB::connection('legacy')->table($legacyTable)->count();
if ($total === 0) {
$this->warn('No legacy news comments found.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_duplicate' => 0,
'skipped_article' => 0,
'skipped_empty' => 0,
'users_unmapped' => 0,
'errors' => 0,
];
$touchedArticleIds = [];
DB::connection('legacy')
->table($legacyTable)
->orderBy('comment_id')
->chunk($chunk, function ($rows) use (&$alreadyImported, $articleMap, $validUserIds, $dryRun, $skipEmpty, &$stats, &$touchedArticleIds): void {
$inserts = [];
foreach ($rows as $row) {
$legacyId = (int) ($row->comment_id ?? 0);
$legacyNewsId = (int) ($row->news_id ?? 0);
$legacyUserId = (int) ($row->user_id ?? 0);
$body = trim((string) ($row->message ?? ''));
if ($legacyId < 1) {
$stats['errors']++;
continue;
}
if (isset($alreadyImported[$legacyId])) {
$stats['skipped_duplicate']++;
continue;
}
if ($body === '') {
if ($skipEmpty) {
$stats['skipped_empty']++;
continue;
}
$body = '[no content]';
}
$articleId = $articleMap[$legacyNewsId] ?? null;
if (! $articleId) {
$stats['skipped_article']++;
continue;
}
$resolvedUserId = isset($validUserIds[$legacyUserId]) ? $legacyUserId : null;
if ($resolvedUserId === null && $legacyUserId > 0) {
$stats['users_unmapped']++;
}
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
$authorName = trim((string) ($row->author ?? ''));
$record = [
'legacy_id' => $legacyId,
'legacy_user_id' => $legacyUserId > 0 ? $legacyUserId : null,
'article_id' => $articleId,
'user_id' => $resolvedUserId,
'parent_id' => null,
'author_name' => $authorName !== '' ? $authorName : null,
'body' => $body,
'rendered_body' => nl2br(e($body)),
'status' => 'visible',
'legacy_posted_at' => $timestamp,
'created_at' => $timestamp,
'updated_at' => $timestamp,
'deleted_at' => null,
];
if (! $dryRun) {
$inserts[] = $record;
$alreadyImported[$legacyId] = true;
$touchedArticleIds[$articleId] = $articleId;
}
$stats['imported']++;
}
if (! $dryRun && $inserts !== []) {
try {
DB::table('news_article_comments')->insert($inserts);
} catch (\Throwable) {
foreach ($inserts as $insert) {
try {
DB::table('news_article_comments')->insertOrIgnore([$insert]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
});
if (! $dryRun && $touchedArticleIds !== []) {
DB::table('news_articles')
->whereIn('id', array_values($touchedArticleIds))
->update(['comments_enabled' => true]);
}
$this->table(
['Result', 'Count'],
[
['Imported', $stats['imported']],
['Skipped - already imported', $stats['skipped_duplicate']],
['Skipped - article missing', $stats['skipped_article']],
['Skipped - empty body', $stats['skipped_empty']],
['Imported with unmapped user', $stats['users_unmapped']],
['Errors', $stats['errors']],
]
);
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveLegacyTable(): ?string
{
$configured = trim((string) $this->option('table'));
if ($configured !== '') {
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
}
foreach (['news_comment', 'news_comments'] as $candidate) {
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
return $candidate;
}
}
return null;
}
private function normalizeTimestamp(mixed $value): string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return now()->toDateTimeString();
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable) {
return now()->toDateTimeString();
}
}
}

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\ArtworkComment;
use App\Models\ArtworkReaction; use App\Models\ArtworkReaction;
use App\Models\CommentReaction; use App\Models\CommentReaction;
use App\Models\NewsArticleComment;
use App\Models\NewsArticleCommentReaction;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; 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 // Shared internals
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -189,4 +218,13 @@ class ReactionController extends Controller
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}]."); 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; namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User; use App\Models\User;
use App\Services\News\NewsService; use App\Services\News\NewsService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -155,10 +156,31 @@ class NewsController extends Controller
->limit(config('news.related_limit', 4)) ->limit(config('news.related_limit', 4))
->get(); ->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', [ return view('news.show', [
'article' => $article, 'article' => $article,
'related' => $related, 'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()), 'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => $comments,
'commentsCount' => $commentsCount,
] + $this->sidebarData()); ] + $this->sidebarData());
} }

View File

@@ -56,6 +56,9 @@ final class StudioNewsController extends Controller
'tagOptions' => $this->news->tagOptions(), 'tagOptions' => $this->news->tagOptions(),
'relationTypeOptions' => $this->news->relationTypeOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(),
'storeUrl' => route('studio.news.store'), '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'), 'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'), 'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'), 'tagsUrl' => route('studio.news.tags'),
@@ -85,7 +88,11 @@ final class StudioNewsController extends Controller
'categoryOptions' => $this->news->categoryOptions(), 'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(), 'tagOptions' => $this->news->tagOptions(),
'relationTypeOptions' => $this->news->relationTypeOptions(), '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]), 'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
'previewUrl' => route('studio.news.preview', ['article' => $article->id]), 'previewUrl' => route('studio.news.preview', ['article' => $article->id]),
'publishUrl' => route('studio.news.publish', ['article' => $article->id]), 'publishUrl' => route('studio.news.publish', ['article' => $article->id]),
'archiveUrl' => route('studio.news.archive', ['article' => $article->id]), 'archiveUrl' => route('studio.news.archive', ['article' => $article->id]),
@@ -115,6 +122,8 @@ final class StudioNewsController extends Controller
'article' => $article, 'article' => $article,
'related' => $related, 'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()), 'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => collect(),
'commentsCount' => 0,
'previewMode' => true, 'previewMode' => true,
'previewCanonical' => route('studio.news.preview', ['article' => $article->id]), 'previewCanonical' => route('studio.news.preview', ['article' => $article->id]),
'previewBackUrl' => route('studio.news.edit', ['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)); $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 public function publish(Request $request, NewsArticle $article): RedirectResponse
@@ -331,7 +349,7 @@ final class StudioNewsController extends Controller
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:800'], 'excerpt' => ['nullable', 'string', 'max:800'],
'content' => ['required', 'string', 'max:50000'], 'content' => ['required', 'string', 'max:500000'],
'cover_image' => ['nullable', 'string', 'max:2048'], 'cover_image' => ['nullable', 'string', 'max:2048'],
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))], 'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'], 'category_id' => ['nullable', 'integer', 'exists:news_categories,id'],
@@ -340,12 +358,24 @@ final class StudioNewsController extends Controller
'published_at' => ['nullable', 'date'], 'published_at' => ['nullable', 'date'],
'is_featured' => ['nullable', 'boolean'], 'is_featured' => ['nullable', 'boolean'],
'is_pinned' => ['nullable', 'boolean'], 'is_pinned' => ['nullable', 'boolean'],
'comments_enabled' => ['nullable', 'boolean'],
'tag_ids' => ['nullable', 'array'], 'tag_ids' => ['nullable', 'array'],
'tag_ids.*' => ['integer', 'exists:news_tags,id'], '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_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'], 'meta_description' => ['nullable', 'string', 'max:300'],
'meta_keywords' => ['nullable', 'string', 'max:255'], '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_title' => ['nullable', 'string', 'max:255'],
'og_description' => ['nullable', 'string', 'max:300'], 'og_description' => ['nullable', 'string', 'max:300'],
'og_image' => ['nullable', 'string', 'max:2048'], 'og_image' => ['nullable', 'string', 'max:2048'],

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Services\ContentSanitizer;
use cPad\Plugins\News\Models\NewsArticle;
class NewsArticleComment extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'legacy_id',
'article_id',
'user_id',
'parent_id',
'legacy_user_id',
'author_name',
'body',
'rendered_body',
'status',
'legacy_posted_at',
];
protected $casts = [
'legacy_id' => 'integer',
'article_id' => 'integer',
'user_id' => 'integer',
'parent_id' => 'integer',
'legacy_user_id' => 'integer',
'legacy_posted_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
public function article(): BelongsTo
{
return $this->belongsTo(NewsArticle::class, 'article_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at')->orderBy('id');
}
public function visibleReplies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('status', 'visible')
->orderBy('created_at')
->orderBy('id')
->with(['user.profile', 'visibleReplies']);
}
public function reactions(): HasMany
{
return $this->hasMany(NewsArticleCommentReaction::class, 'comment_id');
}
public function getDisplayHtml(): string
{
$rendered = is_string($this->rendered_body) ? trim($this->rendered_body) : '';
$body = (string) ($this->body ?? '');
if ($rendered === '') {
$rendered = $body !== ''
? ContentSanitizer::render($body)
: nl2br(e($body));
}
return ContentSanitizer::sanitizeRenderedHtml($rendered, $this->authorCanPublishLinks());
}
private function authorCanPublishLinks(): bool
{
$level = (int) ($this->user?->level ?? 1);
$rank = strtolower((string) ($this->user?->rank ?? 'newbie'));
return $level > 1 && $rank !== 'newbie';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NewsArticleCommentReaction extends Model
{
public $timestamps = false;
protected $table = 'news_article_comment_reactions';
protected $fillable = ['comment_id', 'user_id', 'reaction'];
protected $casts = [
'comment_id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
];
public function comment(): BelongsTo
{
return $this->belongsTo(NewsArticleComment::class, 'comment_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Services\ContentSanitizer;
use App\Models\NewsArticleComment;
use App\Models\User;
use Illuminate\Validation\ValidationException;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentService
{
public function create(NewsArticle $article, User $actor, string $body, ?NewsArticleComment $parent = null): NewsArticleComment
{
if (! $article->commentsAreEnabled()) {
throw ValidationException::withMessages([
'body' => 'Comments are disabled for this article.',
]);
}
$trimmedBody = trim($body);
$errors = ContentSanitizer::validate($trimmedBody);
if ($errors !== []) {
throw ValidationException::withMessages([
'body' => $errors,
]);
}
$comment = NewsArticleComment::query()->create([
'article_id' => (int) $article->id,
'user_id' => (int) $actor->id,
'parent_id' => $parent?->id,
'author_name' => trim((string) ($actor->name ?: $actor->username)),
'body' => $trimmedBody,
'rendered_body' => ContentSanitizer::sanitizeRenderedHtml(
ContentSanitizer::render($trimmedBody),
$this->actorCanPublishLinks($actor)
),
'status' => 'visible',
]);
return $comment->fresh(['user.profile', 'replies.user.profile']);
}
public function delete(NewsArticleComment $comment, User $actor): void
{
$article = $comment->article()->with('author')->first();
if (! $article || ! $this->canDelete($comment, $article, $actor)) {
throw ValidationException::withMessages([
'comment' => 'You are not allowed to remove this comment.',
]);
}
if ($comment->trashed()) {
return;
}
$comment->delete();
}
private function canDelete(NewsArticleComment $comment, NewsArticle $article, User $actor): bool
{
return (int) $comment->user_id === (int) $actor->id
|| (int) $article->author_id === (int) $actor->id
|| $actor->isAdmin()
|| $actor->isModerator();
}
private function actorCanPublishLinks(User $actor): bool
{
$level = (int) ($actor->level ?? 1);
$rank = strtolower((string) ($actor->rank ?? 'Newbie'));
return $level > 1 && $rank !== 'newbie';
}
}

View File

@@ -16,6 +16,7 @@ use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation; use cPad\Plugins\News\Models\NewsArticleRelation;
@@ -173,6 +174,7 @@ final class NewsService
'published_at' => \optional($article->published_at)?->toIso8601String(), 'published_at' => \optional($article->published_at)?->toIso8601String(),
'is_featured' => (bool) $article->is_featured, 'is_featured' => (bool) $article->is_featured,
'is_pinned' => (bool) ($article->is_pinned ?? false), 'is_pinned' => (bool) ($article->is_pinned ?? false),
'comments_enabled' => (bool) ($article->comments_enabled ?? false),
'category_id' => $article->category_id ? (int) $article->category_id : null, 'category_id' => $article->category_id ? (int) $article->category_id : null,
'author_id' => (int) $article->author_id, 'author_id' => (int) $article->author_id,
'author' => $article->author ? $this->mapUserLookupResult($article->author) : null, 'author' => $article->author ? $this->mapUserLookupResult($article->author) : null,
@@ -209,6 +211,11 @@ final class NewsService
return $this->persistArticle($article, $editor, $data); return $this->persistArticle($article, $editor, $data);
} }
public function deleteArticle(NewsArticle $article): void
{
$article->delete();
}
public function publish(NewsArticle $article): NewsArticle public function publish(NewsArticle $article): NewsArticle
{ {
$article->forceFill([ $article->forceFill([
@@ -313,6 +320,8 @@ final class NewsService
$title = 'Untitled News Article'; $title = 'Untitled News Article';
} }
$previousCoverImage = trim((string) ($article->cover_image ?? ''));
$editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT)); $editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT));
$publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at); $publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at);
$authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id); $authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id);
@@ -331,6 +340,7 @@ final class NewsService
'published_at' => $publishedAt, 'published_at' => $publishedAt,
'is_featured' => (bool) ($data['is_featured'] ?? false), 'is_featured' => (bool) ($data['is_featured'] ?? false),
'is_pinned' => (bool) ($data['is_pinned'] ?? false), 'is_pinned' => (bool) ($data['is_pinned'] ?? false),
'comments_enabled' => (bool) ($data['comments_enabled'] ?? false),
'meta_title' => $this->nullableText($data['meta_title'] ?? null), 'meta_title' => $this->nullableText($data['meta_title'] ?? null),
'meta_description' => $this->nullableText($data['meta_description'] ?? null), 'meta_description' => $this->nullableText($data['meta_description'] ?? null),
'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null), 'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null),
@@ -340,9 +350,16 @@ final class NewsService
'og_image' => $this->nullableText($data['og_image'] ?? null), 'og_image' => $this->nullableText($data['og_image'] ?? null),
]); ]);
$article->save(); if (! $article->save()) {
throw new \RuntimeException('Failed to save NewsArticle.');
}
$article->tags()->sync(\collect($data['tag_ids'] ?? [])->map(fn (mixed $id): int => (int) $id)->filter()->all()); $nextCoverImage = trim((string) ($article->cover_image ?? ''));
if ($previousCoverImage !== '' && $previousCoverImage !== $nextCoverImage) {
$this->deleteManagedCoverImage($previousCoverImage);
}
$article->tags()->sync($this->resolveArticleTagIds($data));
$this->syncRelations($article, $data['relations'] ?? []); $this->syncRelations($article, $data['relations'] ?? []);
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']); return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
@@ -365,6 +382,7 @@ final class NewsService
'is_pinned' => (bool) ($article->is_pinned ?? false), 'is_pinned' => (bool) ($article->is_pinned ?? false),
'views' => (int) $article->views, 'views' => (int) $article->views,
'edit_url' => route('studio.news.edit', ['article' => $article->id]), 'edit_url' => route('studio.news.edit', ['article' => $article->id]),
'delete_url' => route('studio.news.destroy', ['article' => $article->id]),
'preview_url' => route('studio.news.preview', ['article' => $article->id]), 'preview_url' => route('studio.news.preview', ['article' => $article->id]),
'public_url' => route('news.show', ['slug' => $article->slug]), 'public_url' => route('news.show', ['slug' => $article->slug]),
]; ];
@@ -437,6 +455,45 @@ final class NewsService
return $text === '' ? null : $text; return $text === '' ? null : $text;
} }
private function resolveArticleTagIds(array $data): array
{
$existingIds = \collect($data['tag_ids'] ?? [])
->map(fn (mixed $id): int => (int) $id)
->filter()
->values();
$createdIds = \collect($data['new_tag_names'] ?? [])
->map(fn (mixed $name): string => trim(preg_replace('/\s+/', ' ', (string) $name) ?? ''))
->filter()
->unique(fn (string $name): string => Str::lower($name))
->map(function (string $name): ?int {
if (Str::slug($name) === '') {
return null;
}
return (int) NewsTag::findOrCreateByName($name)->id;
})
->filter()
->values();
return $existingIds
->merge($createdIds)
->unique()
->values()
->all();
}
private function deleteManagedCoverImage(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
return;
}
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($trimmed);
}
private function searchGroups(string $query, ?User $viewer): array private function searchGroups(string $query, ?User $viewer): array
{ {
return Group::query() return Group::query()

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('news_articles') || Schema::hasColumn('news_articles', 'deleted_at')) {
return;
}
Schema::table('news_articles', function (Blueprint $table): void {
$table->softDeletes()->after('updated_at');
});
}
public function down(): void
{
if (! Schema::hasTable('news_articles') || ! Schema::hasColumn('news_articles', 'deleted_at')) {
return;
}
Schema::table('news_articles', function (Blueprint $table): void {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
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
{
if (Schema::hasTable('news_articles')) {
$this->addNewsArticleColumns();
$this->backfillLegacyNewsIds();
}
if (! Schema::hasTable('news_article_comments')) {
Schema::create('news_article_comments', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('legacy_id')->nullable()->unique();
$table->unsignedInteger('legacy_user_id')->nullable()->index();
$table->foreignId('article_id')->constrained('news_articles')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('news_article_comments')->nullOnDelete();
$table->string('author_name', 120)->nullable();
$table->text('body');
$table->text('rendered_body')->nullable();
$table->string('status', 24)->default('visible');
$table->timestamp('legacy_posted_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['article_id', 'status', 'created_at'], 'news_article_comments_article_status_created_idx');
});
}
}
public function down(): void
{
Schema::dropIfExists('news_article_comments');
if (! Schema::hasTable('news_articles')) {
return;
}
$columns = array_values(array_filter([
'comments_enabled',
'legacy_news_id',
], static fn (string $column): bool => Schema::hasColumn('news_articles', $column)));
if ($columns === []) {
return;
}
Schema::table('news_articles', function (Blueprint $table) use ($columns): void {
foreach ($columns as $column) {
if ($column === 'legacy_news_id') {
$table->dropUnique(['legacy_news_id']);
}
}
$table->dropColumn($columns);
});
}
private function addNewsArticleColumns(): void
{
$needsTableChange = false;
foreach (['comments_enabled', 'legacy_news_id'] as $column) {
if (! Schema::hasColumn('news_articles', $column)) {
$needsTableChange = true;
break;
}
}
if (! $needsTableChange) {
return;
}
Schema::table('news_articles', function (Blueprint $table): void {
if (! Schema::hasColumn('news_articles', 'comments_enabled')) {
$table->boolean('comments_enabled')->default(false)->after('forum_thread_id')->index();
}
if (! Schema::hasColumn('news_articles', 'legacy_news_id')) {
$table->unsignedInteger('legacy_news_id')->nullable()->unique()->after('canonical_url');
}
});
}
private function backfillLegacyNewsIds(): void
{
if (! Schema::hasColumn('news_articles', 'legacy_news_id')) {
return;
}
DB::table('news_articles')
->select(['id', 'canonical_url', 'legacy_news_id'])
->whereNull('legacy_news_id')
->whereNotNull('canonical_url')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$canonicalUrl = trim((string) ($row->canonical_url ?? ''));
if ($canonicalUrl === '' || preg_match('#/legacy/news/(\d+)$#', $canonicalUrl, $matches) !== 1) {
continue;
}
$legacyNewsId = (int) ($matches[1] ?? 0);
if ($legacyNewsId < 1) {
continue;
}
DB::table('news_articles')
->where('id', $row->id)
->update(['legacy_news_id' => $legacyNewsId]);
}
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('news_article_comment_reactions')) {
return;
}
Schema::create('news_article_comment_reactions', function (Blueprint $table): void {
$table->id();
$table->foreignId('comment_id')->constrained('news_article_comments')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('reaction', 32);
$table->timestamp('created_at')->nullable();
$table->unique(['comment_id', 'user_id', 'reaction'], 'news_article_comment_reactions_unique');
$table->index(['comment_id', 'reaction'], 'news_article_comment_reactions_comment_reaction_idx');
});
}
public function down(): void
{
Schema::dropIfExists('news_article_comment_reactions');
}
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,645 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import axios from 'axios'
import CommentForm from '../../components/comments/CommentForm'
import ReactionBar from '../../components/comments/ReactionBar'
import LevelBadge from '../../components/xp/LevelBadge'
import { isFlood } from '../../utils/emojiFlood'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
})
function formatAbsoluteDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_FORMATTER.format(date)
}
function formatAbsoluteDateTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
}
function formatCommentTime(primaryLabel, createdAt) {
return primaryLabel || formatAbsoluteDate(createdAt)
}
function ReplyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
</svg>
)
}
function TrashIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M7.5 2.75A1.75 1.75 0 0 0 5.75 4.5v.25H3.5a.75.75 0 0 0 0 1.5h.54l.853 9.1A2.25 2.25 0 0 0 7.133 17.5h5.734a2.25 2.25 0 0 0 2.24-2.15l.853-9.1h.54a.75.75 0 0 0 0-1.5h-2.25V4.5A1.75 1.75 0 0 0 12.5 2.75h-5Zm5.25 2V4.5a.25.25 0 0 0-.25-.25h-5a.25.25 0 0 0-.25.25v.25h5.5Zm-4 3a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V8.5a.75.75 0 0 1 .75-.75Zm3.25.75a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0V8.5Z" clipRule="evenodd" />
</svg>
)
}
function ChatBubbleIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.2} stroke="currentColor" className="h-10 w-10 text-white/15">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
)
}
function ChevronDownIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
</svg>
)
}
function Avatar({ user, size = 36 }) {
if (user?.avatar_url) {
return (
<img
src={user.avatar_url}
alt={user.display || user.username || ''}
width={size}
height={size}
className="rounded-full object-cover shrink-0 ring-1 ring-white/10"
style={{ width: size, height: size }}
loading="lazy"
onError={(event) => {
event.currentTarget.onerror = null
event.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
)
}
const initials = (user?.display || user?.username || '?').slice(0, 1).toUpperCase()
return (
<span
className="flex items-center justify-center rounded-full bg-gradient-to-br from-nova-600 to-nova-800 text-sm font-bold text-white/90 shrink-0 ring-1 ring-white/10"
style={{ width: size, height: size }}
>
{initials}
</span>
)
}
function DeleteButton({ onDelete, pending = false }) {
return (
<button
type="button"
onClick={onDelete}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium uppercase tracking-wider text-white/35 transition-all duration-200 hover:bg-rose-500/10 hover:text-rose-100 disabled:pointer-events-none disabled:opacity-40"
>
<TrashIcon />
Remove
</button>
)
}
function ReplyItem({ reply, articleId, isLoggedIn, onReplyPosted, onDelete, deletePending = false, depth = 1 }) {
const user = reply.user
const html = reply.rendered_content ?? null
const plain = reply.raw_content ?? ''
const profileLabel = user?.display || user?.username || 'Member'
const replies = reply.replies || []
const reactionEndpoint = `/api/news/comments/${reply.id}/reactions`
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
useEffect(() => {
if (reply.reactions || !reply.id) return
axios
.get(reactionEndpoint)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [reply.id, reply.reactions, reactionEndpoint])
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(reply.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [onReplyPosted, reply.id])
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
const avatarSize = depth >= 3 ? 22 : 28
return (
<li className="rounded-lg bg-white/[0.02] px-3 py-2.5" id={`comment-${reply.id}`}>
<div className="flex gap-2.5">
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1}>
<Avatar user={user} size={avatarSize} />
</a>
) : (
<span className="shrink-0 mt-0.5"><Avatar user={user} size={avatarSize} /></span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{user?.profile_url ? (
<a href={user.profile_url} className="text-[12px] font-semibold text-white/90 hover:text-accent transition-colors">
{profileLabel}
</a>
) : (
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
)}
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={reply.created_at}
title={formatAbsoluteDateTime(reply.created_at)}
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
>
{formatCommentTime(reply.time_ago, reply.created_at)}
</time>
</div>
{html ? (
<div
className="mt-1 text-[12.5px] leading-[1.65] text-white/70 prose prose-invert prose-sm max-w-none prose-p:my-1 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="mt-1 text-[12.5px] leading-[1.65] text-white/70 whitespace-pre-line break-words">{plain}</p>
)}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{isLoggedIn && (
<button
type="button"
onClick={() => setShowReplyForm((value) => !value)}
className={[
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
showReplyForm ? 'bg-accent/10 text-accent' : 'text-white/35 hover:bg-white/[0.06] hover:text-white/65',
].join(' ')}
>
<ReplyIcon />
Reply
</button>
)}
{reply.can_delete ? <DeleteButton onDelete={() => onDelete(reply.id)} pending={deletePending} /> : null}
<ReactionBar
entityType="comment"
entityId={reply.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
endpoint={reactionEndpoint}
/>
</div>
{showReplyForm ? (
<div className="mt-2">
<CommentForm
artworkId={articleId}
submitUrl={`/api/news/articles/${articleId}/comments`}
contentField="content"
maxLength={4000}
placeholder="Share your thoughts about this article…"
onPosted={handleReplyPosted}
parentId={reply.id}
replyTo={profileLabel}
onCancelReply={() => setShowReplyForm(false)}
isLoggedIn={isLoggedIn}
compact
/>
</div>
) : null}
{replies.length > 0 ? (
<div className="mt-2">
<ul className={`space-y-1 pl-3 border-l-2 ${depth >= 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}>
{visibleReplies.map((child) => (
<ReplyItem
key={child.id}
reply={child}
articleId={articleId}
isLoggedIn={isLoggedIn}
onReplyPosted={onReplyPosted}
onDelete={onDelete}
deletePending={deletePending && child.id === reply.id}
depth={depth + 1}
/>
))}
</ul>
{!showAllReplies && hiddenReplyCount > 0 ? (
<button
type="button"
onClick={() => setShowAllReplies(true)}
className="mt-1.5 ml-3 inline-flex items-center gap-1 text-[10px] font-medium text-accent/70 transition-colors hover:text-accent"
>
<ChevronDownIcon className="h-3 w-3" />
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
</button>
) : null}
</div>
) : null}
</div>
</div>
</li>
)
}
function CommentItem({ comment, isLoggedIn, articleId, onReplyPosted, onDelete, deletePending = false }) {
const user = comment.user
const html = comment.rendered_content ?? null
const plain = comment.raw_content ?? ''
const profileLabel = user?.display || user?.username || 'Member'
const replies = comment.replies || []
const flood = isFlood(plain)
const reactionEndpoint = `/api/news/comments/${comment.id}/reactions`
const [expanded, setExpanded] = useState(!flood)
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
.get(reactionEndpoint)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [comment.id, comment.reactions, reactionEndpoint])
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(comment.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [comment.id, onReplyPosted])
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
return (
<li
id={`comment-${comment.id}`}
className="group/comment rounded-2xl border border-white/[0.06] bg-white/[0.03] shadow-[0_1px_3px_rgba(0,0,0,.25)] backdrop-blur-sm transition-all duration-200 hover:border-white/[0.1] hover:bg-white/[0.05]"
>
<div className="p-4 sm:p-5">
<div className="flex gap-3.5">
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={38} />
</a>
) : (
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
)}
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
{user?.profile_url ? (
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
{profileLabel}
</a>
) : (
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
)}
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={comment.created_at}
title={formatAbsoluteDateTime(comment.created_at)}
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
>
{formatCommentTime(comment.time_ago, comment.created_at)}
</time>
</div>
<div className={!expanded ? 'overflow-hidden relative' : undefined} style={!expanded ? { maxHeight: '5em' } : undefined}>
{html ? (
<div
className="text-[13px] leading-[1.7] text-white/80 prose prose-invert prose-sm max-w-none prose-p:my-1.5 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs prose-code:font-normal"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
)}
{flood && !expanded ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-nova-900/95 to-transparent" aria-hidden="true" />
) : null}
</div>
{flood ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="rounded-md px-2 py-0.5 text-xs font-medium text-sky-400 transition-all hover:bg-sky-500/10 hover:text-sky-300"
aria-expanded={expanded}
>
{expanded ? '↑ Collapse' : '↓ Show full comment'}
</button>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{isLoggedIn ? (
<button
type="button"
onClick={() => setShowReplyForm((value) => !value)}
className={[
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
showReplyForm ? 'bg-accent/10 text-accent' : 'text-white/40 hover:bg-white/[0.06] hover:text-white/70',
].join(' ')}
>
<ReplyIcon />
Reply
</button>
) : null}
{comment.can_delete ? <DeleteButton onDelete={() => onDelete(comment.id)} pending={deletePending} /> : null}
<ReactionBar
entityType="comment"
entityId={comment.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
endpoint={reactionEndpoint}
/>
</div>
</div>
</div>
</div>
{(replies.length > 0 || showReplyForm) ? (
<div className="border-t border-white/[0.04] bg-white/[0.01] px-4 pb-4 pt-3 sm:px-5 sm:pb-5">
{replies.length > 0 ? (
<>
<ul className="space-y-1 pl-4 border-l-2 border-white/[0.06]">
{visibleReplies.map((reply) => (
<ReplyItem
key={reply.id}
reply={reply}
articleId={articleId}
isLoggedIn={isLoggedIn}
onReplyPosted={onReplyPosted}
onDelete={onDelete}
deletePending={deletePending && reply.id === comment.id}
/>
))}
</ul>
{!showAllReplies && hiddenReplyCount > 0 ? (
<button
type="button"
onClick={() => setShowAllReplies(true)}
className="mt-2 ml-4 inline-flex items-center gap-1 text-[11px] font-medium text-accent/70 transition-colors hover:text-accent"
>
<ChevronDownIcon className="h-3.5 w-3.5" />
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
</button>
) : null}
</>
) : null}
{showReplyForm ? (
<div className={replies.length > 0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}>
<CommentForm
artworkId={articleId}
submitUrl={`/api/news/articles/${articleId}/comments`}
contentField="content"
maxLength={4000}
placeholder="Share your thoughts about this article…"
onPosted={handleReplyPosted}
parentId={comment.id}
replyTo={profileLabel}
onCancelReply={() => setShowReplyForm(false)}
isLoggedIn={isLoggedIn}
compact
/>
</div>
) : null}
</div>
) : null}
</li>
)
}
function Skeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
style={{ animationDelay: `${index * 120}ms` }}
>
<div className="w-[38px] h-[38px] rounded-full bg-white/[0.06] shrink-0" />
<div className="flex-1 space-y-3 pt-1">
<div className="flex gap-2.5">
<div className="h-3 bg-white/[0.06] rounded-full w-24" />
<div className="h-3 bg-white/[0.04] rounded-full w-14" />
</div>
<div className="space-y-2">
<div className="h-3 bg-white/[0.05] rounded-full w-full" />
<div className="h-3 bg-white/[0.04] rounded-full w-4/5" />
<div className="h-3 bg-white/[0.03] rounded-full w-2/5" />
</div>
</div>
</div>
))}
</div>
)
}
function NewsComments({ articleId, isLoggedIn = false, loginUrl = '/login' }) {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [lastPage, setLastPage] = useState(1)
const [total, setTotal] = useState(0)
const [deletingId, setDeletingId] = useState(null)
const initialized = useRef(false)
const loadComments = useCallback(async (nextPage = 1) => {
if (!articleId) return
setLoading(true)
try {
const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`)
if (nextPage === 1) {
setComments(data.data ?? [])
} else {
setComments((current) => [...current, ...(data.data ?? [])])
}
setPage(data.meta?.current_page ?? nextPage)
setLastPage(data.meta?.last_page ?? 1)
setTotal(data.meta?.total ?? 0)
} catch {
// Keep the server-rendered fallback in place if the API is unavailable.
} finally {
setLoading(false)
}
}, [articleId])
useEffect(() => {
if (initialized.current) return
initialized.current = true
loadComments(1)
}, [loadComments])
const handlePosted = useCallback((newComment) => {
const comment = { ...newComment, replies: newComment.replies || [] }
setComments((current) => [comment, ...current])
setTotal((current) => current + 1)
}, [])
const handleReplyPosted = useCallback((parentId, newReply) => {
const insertReply = (nodes) => nodes.map((comment) => {
if (comment.id === parentId) {
return { ...comment, replies: [...(comment.replies || []), { ...newReply, replies: [] }] }
}
if (comment.replies?.length) {
return { ...comment, replies: insertReply(comment.replies) }
}
return comment
})
setComments((current) => insertReply(current))
setTotal((current) => current + 1)
}, [])
const handleDelete = useCallback(async (commentId) => {
if (!commentId || deletingId) return
setDeletingId(commentId)
try {
await axios.delete(`/api/news/articles/${articleId}/comments/${commentId}`)
await loadComments(1)
} catch {
// Preserve the current state if deletion fails.
} finally {
setDeletingId(null)
}
}, [articleId, deletingId, loadComments])
return (
<section aria-label="Comments" className="space-y-6">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">Comments</h2>
{total > 0 ? (
<span className="inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50">
{total}
</span>
) : null}
</div>
{loading && comments.length === 0 ? (
<Skeleton />
) : comments.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
<ChatBubbleIcon />
<p className="text-sm font-medium text-white/40">No comments yet</p>
<p className="text-xs text-white/25">Be the first to share your thoughts.</p>
</div>
) : (
<>
<ul className="space-y-3 sm:space-y-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
articleId={articleId}
isLoggedIn={isLoggedIn}
onReplyPosted={handleReplyPosted}
onDelete={handleDelete}
deletePending={deletingId === comment.id}
/>
))}
</ul>
{page < lastPage ? (
<div className="flex justify-center pt-3">
<button
type="button"
disabled={loading}
onClick={() => loadComments(page + 1)}
className="group relative rounded-full border border-white/[0.08] bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-white/50 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80 hover:shadow-lg hover:shadow-black/20 disabled:opacity-40 disabled:pointer-events-none"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading
</span>
) : 'Load more comments'}
</button>
</div>
) : null}
</>
)}
{articleId ? (
<CommentForm
artworkId={articleId}
submitUrl={`/api/news/articles/${articleId}/comments`}
contentField="content"
maxLength={4000}
placeholder="Share your thoughts about this article…"
onPosted={handlePosted}
isLoggedIn={isLoggedIn}
loginUrl={loginUrl}
/>
) : null}
</section>
)
}
if (typeof document !== 'undefined') {
const mountEl = document.getElementById('news-comments-root')
const propsEl = document.getElementById('news-comments-props')
if (mountEl && propsEl) {
let props = {}
try {
props = JSON.parse(propsEl.textContent || '{}')
} catch {
props = {}
}
createRoot(mountEl).render(<NewsComments {...props} />)
}
}
export default NewsComments

View File

@@ -3,6 +3,7 @@ import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor' import RichTextEditor from '../../components/forum/RichTextEditor'
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField' import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect' import NovaSelect from '../../components/ui/NovaSelect'
import { Checkbox } from '../../components/ui' import { Checkbox } from '../../components/ui'
@@ -312,6 +313,7 @@ function buildSubmitPayload(data) {
published_at: data.published_at ? String(data.published_at) : null, published_at: data.published_at ? String(data.published_at) : null,
is_featured: Boolean(data.is_featured), is_featured: Boolean(data.is_featured),
is_pinned: Boolean(data.is_pinned), is_pinned: Boolean(data.is_pinned),
comments_enabled: Boolean(data.comments_enabled),
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [], tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [], new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
meta_title: String(data.meta_title || ''), meta_title: String(data.meta_title || ''),
@@ -345,6 +347,7 @@ function buildInitialFormData(article, defaultAuthor, typeOptions) {
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '', published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured), is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned), is_pinned: Boolean(article.is_pinned),
comments_enabled: Boolean(article.comments_enabled),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [], tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
new_tag_names: [], new_tag_names: [],
meta_title: article.meta_title || '', meta_title: article.meta_title || '',
@@ -679,11 +682,11 @@ export default function StudioNewsEditor() {
<div className="grid gap-2 text-sm text-slate-300"> <div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} /> <NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} />
</div> </div>
<label className="grid gap-2 text-sm text-slate-300"> <div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /> <DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue)} placeholder="Pick a publish slot" clearable className="bg-black/20" />
<FieldError message={form.errors.published_at} /> <FieldError message={form.errors.published_at} />
</label> </div>
</div> </div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4"> <div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
@@ -714,6 +717,11 @@ export default function StudioNewsEditor() {
<Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" /> <Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.comments_enabled} onChange={(event) => form.setData('comments_enabled', event.target.checked)} label="Allow comments on the article page" size={20} variant="accent" />
<FieldError message={form.errors.comments_enabled} />
</div>
</div> </div>
</SectionCard> </SectionCard>

View File

@@ -85,6 +85,12 @@ export default function CommentForm({
replyTo = null, replyTo = null,
onCancelReply = null, onCancelReply = null,
compact = false, compact = false,
submitUrl = null,
contentField = 'content',
maxLength = 10000,
placeholder = 'Share your thoughts…',
submitLabel = 'Post comment',
submittingLabel = 'Posting…',
}) { }) {
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [tab, setTab] = useState('write') // 'write' | 'preview' const [tab, setTab] = useState('write') // 'write' | 'preview'
@@ -92,6 +98,8 @@ export default function CommentForm({
const [errors, setErrors] = useState([]) const [errors, setErrors] = useState([])
const textareaRef = useRef(null) const textareaRef = useRef(null)
const formRef = useRef(null) const formRef = useRef(null)
const resolvedSubmitUrl = submitUrl || (artworkId ? `/api/artworks/${artworkId}/comments` : null)
const warningThreshold = Math.max(1, Math.floor(maxLength * 0.9))
// Auto-focus when entering reply mode // Auto-focus when entering reply mode
useEffect(() => { useEffect(() => {
@@ -237,14 +245,14 @@ export default function CommentForm({
} }
const trimmed = content.trim() const trimmed = content.trim()
if (!trimmed) return if (!trimmed || !resolvedSubmitUrl) return
setSubmitting(true) setSubmitting(true)
setErrors([]) setErrors([])
try { try {
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, { const { data } = await axios.post(resolvedSubmitUrl, {
content: trimmed, [contentField]: trimmed,
parent_id: parentId || null, parent_id: parentId || null,
}) })
@@ -255,7 +263,12 @@ export default function CommentForm({
} catch (err) { } catch (err) {
if (err.response?.status === 422) { if (err.response?.status === 422) {
const fieldErrors = err.response.data?.errors ?? {} const fieldErrors = err.response.data?.errors ?? {}
const allErrors = Object.values(fieldErrors).flat() const allErrors = [
...(Array.isArray(fieldErrors[contentField]) ? fieldErrors[contentField] : []),
...Object.entries(fieldErrors)
.filter(([field]) => field !== contentField)
.flatMap(([, messages]) => Array.isArray(messages) ? messages : []),
]
setErrors(allErrors.length ? allErrors : ['Invalid content.']) setErrors(allErrors.length ? allErrors : ['Invalid content.'])
} else { } else {
setErrors(['Something went wrong. Please try again.']) setErrors(['Something went wrong. Please try again.'])
@@ -264,7 +277,7 @@ export default function CommentForm({
setSubmitting(false) setSubmitting(false)
} }
}, },
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply], [content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl],
) )
/* ── Logged-out state ─────────────────────────────────────────────────── */ /* ── Logged-out state ─────────────────────────────────────────────────── */
@@ -342,10 +355,10 @@ export default function CommentForm({
<span <span
className={[ className={[
'text-[11px] tabular-nums font-medium transition-colors', 'text-[11px] tabular-nums font-medium transition-colors',
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20', content.length > warningThreshold ? 'text-amber-400/80' : 'text-white/20',
].join(' ')} ].join(' ')}
> >
{content.length > 0 && `${content.length.toLocaleString()}/10,000`} {content.length > 0 && `${content.length.toLocaleString()}/${maxLength.toLocaleString()}`}
</span> </span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} /> <EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
</div> </div>
@@ -385,9 +398,9 @@ export default function CommentForm({
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={replyTo ? `Reply to ${replyTo}` : 'Share your thoughts…'} placeholder={replyTo ? `Reply to ${replyTo}` : placeholder}
rows={compact ? 2 : 4} rows={compact ? 2 : 4}
maxLength={10000} maxLength={maxLength}
disabled={submitting} disabled={submitting}
aria-label="Comment text" aria-label="Comment text"
className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50" className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50"
@@ -451,10 +464,10 @@ export default function CommentForm({
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg> </svg>
Posting {submittingLabel}
</span> </span>
) : ( ) : (
'Post comment' submitLabel
)} )}
</button> </button>
</div> </div>

View File

@@ -32,18 +32,24 @@ function HeartOutlineIcon({ className }) {
* entityId number * entityId number
* initialTotals Record<slug, { emoji, label, count, mine }> * initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean * isLoggedIn boolean
* endpoint string | null
*/ */
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) { export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false, endpoint = null }) {
const [totals, setTotals] = useState(initialTotals) const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null) const [loading, setLoading] = useState(null)
const [pickerOpen, setPickerOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false)
const containerRef = useRef(null) const containerRef = useRef(null)
const hoverTimeout = useRef(null) const hoverTimeout = useRef(null)
const endpoint = useEffect(() => {
setTotals(initialTotals ?? {})
}, [entityId, initialTotals])
const resolvedEndpoint = endpoint || (
entityType === 'artwork' entityType === 'artwork'
? `/api/artworks/${entityId}/reactions` ? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions` : `/api/comments/${entityId}/reactions`
)
// Close picker when clicking outside // Close picker when clicking outside
useEffect(() => { useEffect(() => {
@@ -81,7 +87,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
}) })
try { try {
const { data } = await axios.post(endpoint, { reaction: slug }) const { data } = await axios.post(resolvedEndpoint, { reaction: slug })
setTotals(data.totals) setTotals(data.totals)
} catch { } catch {
setTotals((prev) => { setTotals((prev) => {
@@ -99,7 +105,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
setLoading(null) setLoading(null)
} }
}, },
[endpoint, isLoggedIn, loading], [resolvedEndpoint, isLoggedIn, loading],
) )
// Compute summary data // Compute summary data
@@ -167,7 +173,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
aria-label={isArtworkVariant aria-label={isArtworkVariant
? (myReaction ? (myReaction
? `Open reaction picker. Current reaction: ${myReactionData?.label}.` ? `Open reaction picker. Current reaction: ${myReactionData?.label}.`
: 'Open reaction picker for this artwork') : 'React to this artwork')
: (myReaction : (myReaction
? `You reacted with ${myReactionData?.label}. Click to remove.` ? `You reacted with ${myReactionData?.label}. Click to remove.`
: 'React to this comment')} : 'React to this comment')}

View File

@@ -0,0 +1,120 @@
@php
$commentsCollection = $comments ?? collect();
$commentsCount = $commentsCount ?? $commentsCollection->count();
$viewer = auth()->user();
@endphp
@if($isPreview)
@if($article->commentsAreEnabled())
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-5 py-4 text-sm text-indigo-100">
Comments are enabled for this article, but posting is disabled in preview mode.
</div>
</section>
@endif
@elseif($article->commentsAreEnabled())
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="flex flex-col gap-3 border-b border-white/[0.06] pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Conversation</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{{ number_format((int) $commentsCount) }} {{ (int) $commentsCount === 1 ? 'Comment' : 'Comments' }}</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-white/50">Keep the discussion focused on the article. Safe markdown formatting is supported for signed-in members.</p>
</div>
</div>
@if(session('status'))
<div class="mt-6 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{{ session('status') }}
</div>
@endif
@if($errors->has('body'))
<div class="mt-6 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{{ $errors->first('body') }}
</div>
@endif
@auth
<form method="POST" action="{{ route('news.comments.store', ['slug' => $article->slug]) }}" class="mt-6 rounded-[28px] border border-white/[0.06] bg-black/20 p-4 sm:p-5">
@csrf
<label class="block">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Add your comment</span>
<textarea name="body" rows="4" maxlength="4000" placeholder="Share your thoughts about this article" class="mt-3 w-full rounded-2xl border border-white/[0.08] bg-black/30 px-4 py-3 text-sm leading-6 text-white placeholder:text-white/25 outline-none transition focus:border-sky-400/40 focus:bg-black/40">{{ old('body') }}</textarea>
</label>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<p class="text-xs leading-5 text-white/35">Basic markdown is supported. Replies and reactions use the enhanced editor when JavaScript is available.</p>
<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-500/15">
<i class="fa-solid fa-paper-plane text-xs"></i>
Post comment
</button>
</div>
</form>
@else
<div class="mt-6 rounded-[28px] border border-white/[0.06] bg-black/20 p-5 text-sm text-white/55">
<a href="{{ route('login') }}" class="font-semibold text-sky-200 transition hover:text-white">Sign in</a>
to join the discussion on this article.
</div>
@endauth
@if($commentsCollection->isEmpty())
<div class="mt-6 rounded-[28px] border border-dashed border-white/[0.08] bg-white/[0.02] px-6 py-12 text-center">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.03] text-white/25">
<i class="fa-regular fa-comments text-xl"></i>
</div>
<h3 class="mt-4 text-lg font-semibold text-white">No comments yet</h3>
<p class="mx-auto mt-2 max-w-xl text-sm leading-6 text-white/40">Start the conversation if you have feedback, context, or a question about this update.</p>
</div>
@else
<div class="mt-6 space-y-4">
@foreach($commentsCollection as $comment)
@php
$commentUser = $comment->user;
$displayName = $commentUser?->name ?: $commentUser?->username ?: $comment->author_name ?: 'Former member';
$username = $commentUser?->username;
$profileUrl = $username ? '/@' . strtolower((string) $username) : null;
$canDelete = $viewer && ((int) $viewer->id === (int) $comment->user_id || (int) $viewer->id === (int) $article->author_id || $viewer->isAdmin() || $viewer->isModerator());
@endphp
<article class="rounded-[28px] border border-white/[0.06] bg-black/20 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
@if($commentUser)
<img src="{{ \App\Support\AvatarUrl::forUser((int) $commentUser->id, $commentUser->profile?->avatar_hash, 64) }}" alt="{{ $displayName }}" class="h-11 w-11 rounded-full border border-white/[0.08] object-cover">
@else
<div class="flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] text-sm font-semibold text-white/55">
{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr($displayName, 0, 1)) }}
</div>
@endif
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
@if($profileUrl)
<a href="{{ $profileUrl }}" class="truncate text-sm font-semibold text-white transition hover:text-sky-300">{{ $displayName }}</a>
<span class="text-xs uppercase tracking-[0.16em] text-white/25">{{ '@' . $username }}</span>
@else
<span class="truncate text-sm font-semibold text-white">{{ $displayName }}</span>
@endif
</div>
<p class="mt-1 text-xs uppercase tracking-[0.16em] text-white/25">{{ optional($comment->created_at)->diffForHumans() ?? 'Unknown time' }}</p>
</div>
</div>
@if($canDelete)
<form method="POST" action="{{ route('news.comments.destroy', ['slug' => $article->slug, 'comment' => $comment->id]) }}">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-white/55 transition hover:border-rose-300/20 hover:bg-rose-500/10 hover:text-rose-100">
<i class="fa-regular fa-trash-can text-[11px]"></i>
Remove
</button>
</form>
@endif
</div>
<div class="story-prose prose prose-invert mt-4 max-w-none text-[0.98rem] leading-7 prose-p:text-white/74">
{!! $comment->getDisplayHtml() !!}
</div>
</article>
@endforeach
</div>
@endif
</section>
@endif

View File

@@ -64,6 +64,7 @@
:breadcrumbs="$headerBreadcrumbs" :breadcrumbs="$headerBreadcrumbs"
:description="$article->excerpt ? Str::limit(strip_tags((string) $article->excerpt), 180) : 'Latest Skinbase announcement and community update.'" :description="$article->excerpt ? Str::limit(strip_tags((string) $article->excerpt), 180) : 'Latest Skinbase announcement and community update.'"
headerClass="pb-6" headerClass="pb-6"
innerClass="mx-auto max-w-7xl"
> >
<x-slot name="actions"> <x-slot name="actions">
<div class="flex flex-wrap items-center gap-2 text-sm text-white/60"> <div class="flex flex-wrap items-center gap-2 text-sm text-white/60">
@@ -125,7 +126,7 @@
<p class="mt-5 text-lg leading-8 text-white/65">{{ $article->excerpt }}</p> <p class="mt-5 text-lg leading-8 text-white/65">{{ $article->excerpt }}</p>
@endif @endif
<div class="prose prose-invert prose-sky mt-8 max-w-none prose-p:text-white/72 prose-li:text-white/70 prose-strong:text-white prose-a:text-sky-300 hover:prose-a:text-sky-200 prose-headings:text-white"> <div class="story-prose prose prose-invert mt-8 max-w-none text-[1.02rem] leading-8 prose-p:text-white/72 prose-strong:text-white prose-headings:text-white [&_img]:rounded-[24px] [&_img]:border [&_img]:border-white/[0.08] [&_img]:shadow-[0_20px_45px_rgba(0,0,0,0.24)]">
{!! $article->rendered_content !!} {!! $article->rendered_content !!}
</div> </div>
@@ -161,6 +162,34 @@
</a> </a>
</div> </div>
@endif @endif
@if($article->commentsAreEnabled() && ! $isPreview)
<script id="news-comments-props" type="application/json">
{!! json_encode([
'articleId' => (int) $article->id,
'isLoggedIn' => auth()->check(),
'loginUrl' => route('login'),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
</script>
<div id="news-comments-root">
@include('news._comments', [
'article' => $article,
'comments' => $comments ?? collect(),
'commentsCount' => $commentsCount ?? 0,
'isPreview' => $isPreview,
])
</div>
@vite(['resources/js/Pages/News/NewsComments.jsx'])
@else
@include('news._comments', [
'article' => $article,
'comments' => $comments ?? collect(),
'commentsCount' => $commentsCount ?? 0,
'isPreview' => $isPreview,
])
@endif
</div> </div>
@include('news._related_entities', ['relatedEntities' => $relatedEntities ?? []]) @include('news._related_entities', ['relatedEntities' => $relatedEntities ?? []])

View File

@@ -192,6 +192,7 @@ it('stores a newsroom draft with taxonomy links', function (): void {
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT, 'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
'published_at' => null, 'published_at' => null,
'tag_ids' => [$tag->id], 'tag_ids' => [$tag->id],
'new_tag_names' => ['Studio Exclusive'],
'is_featured' => true, 'is_featured' => true,
'is_pinned' => false, 'is_pinned' => false,
'meta_title' => 'Stored newsroom draft meta', 'meta_title' => 'Stored newsroom draft meta',
@@ -212,5 +213,89 @@ it('stores a newsroom draft with taxonomy links', function (): void {
'status' => 'draft', 'status' => 'draft',
]); ]);
expect($article->tags()->pluck('news_tags.id')->all())->toBe([$tag->id]); expect($article->tags()->pluck('news_tags.name')->all())
->toContain('Update')
->toContain('Studio Exclusive');
});
it('updates an existing newsroom article', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
]);
$author = User::factory()->create();
$category = studioNewsCategory([
'name' => 'Editorial',
'slug' => 'editorial',
]);
$tag = studioNewsTag([
'name' => 'Feature',
'slug' => 'feature',
]);
$article = NewsArticle::query()->create([
'title' => 'Original newsroom article',
'slug' => 'original-newsroom-article',
'excerpt' => 'Original excerpt.',
'content' => 'Original content.',
'author_id' => $author->id,
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
'status' => 'draft',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
]);
$this->actingAs($moderator)
->patch(route('studio.news.update', ['article' => $article->id]), [
'title' => 'Updated newsroom article',
'slug' => 'updated-newsroom-article',
'excerpt' => 'Updated excerpt.',
'content' => '<p>Updated content.</p>',
'type' => NewsArticle::TYPE_EDITORIAL,
'category_id' => $category->id,
'author_id' => $author->id,
'editorial_status' => NewsArticle::EDITORIAL_STATUS_IN_REVIEW,
'tag_ids' => [$tag->id],
'new_tag_names' => ['Deep Dive'],
'is_featured' => true,
'is_pinned' => true,
])
->assertSessionHasNoErrors()
->assertRedirect();
$article->refresh();
expect($article->title)->toBe('Updated newsroom article')
->and($article->slug)->toBe('updated-newsroom-article')
->and($article->type)->toBe(NewsArticle::TYPE_EDITORIAL)
->and($article->editorial_status)->toBe(NewsArticle::EDITORIAL_STATUS_IN_REVIEW)
->and((int) $article->category_id)->toBe($category->id)
->and((bool) $article->is_featured)->toBeTrue()
->and((bool) $article->is_pinned)->toBeTrue()
->and($article->tags()->pluck('news_tags.name')->all())->toContain('Feature')
->and($article->tags()->pluck('news_tags.name')->all())->toContain('Deep Dive');
});
it('soft deletes a newsroom article from studio', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
]);
$author = User::factory()->create();
$article = NewsArticle::query()->create([
'title' => 'Delete me softly',
'slug' => 'delete-me-softly',
'excerpt' => 'Soft delete test article.',
'content' => 'Studio delete content.',
'author_id' => $author->id,
'type' => NewsArticle::TYPE_EDITORIAL,
'status' => 'draft',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
]);
$this->actingAs($moderator)
->delete(route('studio.news.destroy', ['article' => $article->id]))
->assertRedirect(route('studio.news.index'));
$this->assertSoftDeleted('news_articles', [
'id' => $article->id,
]);
}); });