$filters */ public function list(User $user, array $filters = []): array { $module = $this->normalizeModule((string) ($filters['module'] ?? 'all')); $query = trim((string) ($filters['q'] ?? '')); $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = min(max((int) ($filters['per_page'] ?? 18), 12), 36); $items = $this->artworkComments($user) ->concat($this->cardComments($user)) ->concat($this->collectionComments($user)) ->concat($this->storyComments($user)) ->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0) ->values(); if ($module !== 'all') { $items = $items->where('module', $module)->values(); } if ($query !== '') { $needle = mb_strtolower($query); $items = $items->filter(function (array $item) use ($needle): bool { return collect([ $item['author_name'] ?? '', $item['item_title'] ?? '', $item['body'] ?? '', $item['module_label'] ?? '', ])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle)); })->values(); } $total = $items->count(); $lastPage = max(1, (int) ceil($total / $perPage)); $page = min($page, $lastPage); return [ 'items' => $items->forPage($page, $perPage)->values()->all(), 'meta' => [ 'current_page' => $page, 'last_page' => $lastPage, 'per_page' => $perPage, 'total' => $total, ], 'filters' => [ 'module' => $module, 'q' => $query, ], 'module_options' => [ ['value' => 'all', 'label' => 'All comments'], ['value' => 'artworks', 'label' => 'Artworks'], ['value' => 'cards', 'label' => 'Cards'], ['value' => 'collections', 'label' => 'Collections'], ['value' => 'stories', 'label' => 'Stories'], ], ]; } public function reply(User $user, string $module, int $commentId, string $content): void { $trimmed = trim($content); abort_if($trimmed === '' || mb_strlen($trimmed) > 10000, 422, 'Reply content is invalid.'); match ($this->normalizeModule($module)) { 'artworks' => $this->replyToArtworkComment($user, $commentId, $trimmed), 'cards' => $this->replyToCardComment($user, $commentId, $trimmed), 'collections' => $this->replyToCollectionComment($user, $commentId, $trimmed), 'stories' => $this->replyToStoryComment($user, $commentId, $trimmed), default => abort(404), }; } public function moderate(User $user, string $module, int $commentId): void { match ($this->normalizeModule($module)) { 'artworks' => $this->deleteArtworkComment($user, $commentId), 'cards' => $this->deleteCardComment($user, $commentId), 'collections' => $this->deleteCollectionComment($user, $commentId), 'stories' => $this->deleteStoryComment($user, $commentId), default => abort(404), }; } public function report(User $user, string $module, int $commentId, string $reason, ?string $details = null): array { $targetType = match ($this->normalizeModule($module)) { 'artworks' => 'artwork_comment', 'cards' => 'nova_card_comment', 'collections' => 'collection_comment', 'stories' => 'story_comment', default => abort(404), }; $this->reports->validateForReporter($user, $targetType, $commentId); $report = Report::query()->create([ 'reporter_id' => $user->id, 'target_type' => $targetType, 'target_id' => $commentId, 'reason' => trim($reason), 'details' => $details ? trim($details) : null, 'status' => 'open', ]); return [ 'id' => (int) $report->id, 'status' => (string) $report->status, ]; } private function artworkComments(User $user) { return ArtworkComment::query() ->with(['user.profile', 'artwork']) ->whereNull('deleted_at') ->whereHas('artwork', fn ($query) => $query->where('user_id', $user->id)) ->latest('created_at') ->limit(120) ->get() ->map(fn (ArtworkComment $comment): array => [ 'id' => 'artworks:' . $comment->id, 'comment_id' => (int) $comment->id, 'module' => 'artworks', 'module_label' => 'Artworks', 'target_type' => 'artwork_comment', 'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator', 'author_username' => $comment->user?->username, 'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64), 'item_title' => $comment->artwork?->title, 'item_id' => (int) ($comment->artwork?->id ?? 0), 'body' => (string) ($comment->raw_content ?: $comment->content), 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'context_url' => $comment->artwork ? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug]) . '#comment-' . $comment->id : route('studio.comments'), 'preview_url' => $comment->artwork ? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug]) : null, 'reply_supported' => true, 'moderate_supported' => true, 'report_supported' => true, ]); } private function cardComments(User $user) { return NovaCardComment::query() ->with(['user.profile', 'card']) ->whereNull('deleted_at') ->whereHas('card', fn ($query) => $query->where('user_id', $user->id)) ->latest('created_at') ->limit(120) ->get() ->map(fn (NovaCardComment $comment): array => [ 'id' => 'cards:' . $comment->id, 'comment_id' => (int) $comment->id, 'module' => 'cards', 'module_label' => 'Cards', 'target_type' => 'nova_card_comment', 'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator', 'author_username' => $comment->user?->username, 'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64), 'item_title' => $comment->card?->title, 'item_id' => (int) ($comment->card?->id ?? 0), 'body' => (string) $comment->body, 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'context_url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.comments'), 'preview_url' => $comment->card ? route('studio.cards.preview', ['id' => $comment->card->id]) : null, 'reply_supported' => true, 'moderate_supported' => true, 'report_supported' => true, ]); } private function collectionComments(User $user) { return CollectionComment::query() ->with(['user.profile', 'collection']) ->whereNull('deleted_at') ->whereHas('collection', fn ($query) => $query->where('user_id', $user->id)) ->latest('created_at') ->limit(120) ->get() ->map(fn (CollectionComment $comment): array => [ 'id' => 'collections:' . $comment->id, 'comment_id' => (int) $comment->id, 'module' => 'collections', 'module_label' => 'Collections', 'target_type' => 'collection_comment', 'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator', 'author_username' => $comment->user?->username, 'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64), 'item_title' => $comment->collection?->title, 'item_id' => (int) ($comment->collection?->id ?? 0), 'body' => (string) $comment->body, 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'context_url' => $comment->collection ? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug]) . '#comment-' . $comment->id : route('studio.comments'), 'preview_url' => $comment->collection ? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug]) : null, 'reply_supported' => true, 'moderate_supported' => true, 'report_supported' => true, ]); } private function storyComments(User $user) { return StoryComment::query() ->with(['user.profile', 'story']) ->whereNull('deleted_at') ->whereHas('story', fn ($query) => $query->where('creator_id', $user->id)) ->latest('created_at') ->limit(120) ->get() ->map(fn (StoryComment $comment): array => [ 'id' => 'stories:' . $comment->id, 'comment_id' => (int) $comment->id, 'module' => 'stories', 'module_label' => 'Stories', 'target_type' => 'story_comment', 'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator', 'author_username' => $comment->user?->username, 'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64), 'item_title' => $comment->story?->title, 'item_id' => (int) ($comment->story?->id ?? 0), 'body' => (string) ($comment->raw_content ?: $comment->content), 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'context_url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.comments'), 'preview_url' => $comment->story ? route('creator.stories.preview', ['story' => $comment->story->id]) : null, 'reply_supported' => true, 'moderate_supported' => true, 'report_supported' => true, ]); } private function replyToArtworkComment(User $user, int $commentId, string $content): void { $comment = ArtworkComment::query() ->with('artwork') ->findOrFail($commentId); abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403); $errors = ContentSanitizer::validate($content); if ($errors !== []) { abort(422, implode(' ', $errors)); } ArtworkComment::query()->create([ 'artwork_id' => $comment->artwork_id, 'user_id' => $user->id, 'parent_id' => $comment->id, 'content' => $content, 'raw_content' => $content, 'rendered_content' => ContentSanitizer::render($content), 'is_approved' => true, ]); $this->syncArtworkCommentCount((int) $comment->artwork_id); } private function replyToCardComment(User $user, int $commentId, string $content): void { $comment = NovaCardComment::query()->with(['card.user'])->findOrFail($commentId); abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403); $this->cardComments->create($comment->card->loadMissing('user'), $user, $content, $comment); } private function replyToCollectionComment(User $user, int $commentId, string $content): void { $comment = CollectionComment::query()->with(['collection.user'])->findOrFail($commentId); abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403); $this->collectionComments->create($comment->collection->loadMissing('user'), $user, $content, $comment); } private function replyToStoryComment(User $user, int $commentId, string $content): void { $comment = StoryComment::query()->with('story')->findOrFail($commentId); abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403); $this->social->addStoryComment($user, $comment->story, $content, $comment->id); } private function deleteArtworkComment(User $user, int $commentId): void { $comment = ArtworkComment::query()->with('artwork')->findOrFail($commentId); abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403); if (! $comment->trashed()) { $comment->delete(); } $this->syncArtworkCommentCount((int) $comment->artwork_id); } private function deleteCardComment(User $user, int $commentId): void { $comment = NovaCardComment::query()->with('card')->findOrFail($commentId); abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403); $this->cardComments->delete($comment, $user); } private function deleteCollectionComment(User $user, int $commentId): void { $comment = CollectionComment::query()->with('collection')->findOrFail($commentId); abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403); $this->collectionComments->delete($comment, $user); } private function deleteStoryComment(User $user, int $commentId): void { $comment = StoryComment::query()->with('story')->findOrFail($commentId); abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403); $this->social->deleteStoryComment($user, $comment); } private function syncArtworkCommentCount(int $artworkId): void { $count = ArtworkComment::query() ->where('artwork_id', $artworkId) ->whereNull('deleted_at') ->count(); if (DB::table('artwork_stats')->where('artwork_id', $artworkId)->exists()) { DB::table('artwork_stats') ->where('artwork_id', $artworkId) ->update(['comments_count' => $count, 'updated_at' => now()]); } } private function normalizeModule(string $module): string { return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true) ? $module : 'all'; } }