'Group', self::RELATION_ARTWORK => 'Artwork', self::RELATION_COLLECTION => 'Collection', self::RELATION_RELEASE => 'Release', self::RELATION_PROJECT => 'Project', self::RELATION_CHALLENGE => 'Challenge', self::RELATION_EVENT => 'Event', self::RELATION_USER => 'Profile', ]; public function articleTypeOptions(): array { return \collect(NewsArticle::TYPE_LABELS) ->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label]) ->values() ->all(); } public function editorialStatusOptions(): array { return [ ['value' => NewsArticle::EDITORIAL_STATUS_DRAFT, 'label' => 'Draft'], ['value' => NewsArticle::EDITORIAL_STATUS_IN_REVIEW, 'label' => 'In review'], ['value' => NewsArticle::EDITORIAL_STATUS_SCHEDULED, 'label' => 'Scheduled'], ['value' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, 'label' => 'Published'], ['value' => NewsArticle::EDITORIAL_STATUS_ARCHIVED, 'label' => 'Archived'], ]; } public function relationTypeOptions(): array { return \collect(self::RELATION_LABELS) ->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label]) ->values() ->all(); } public function categoryOptions(): array { return NewsCategory::query() ->ordered() ->get(['id', 'name']) ->map(fn (NewsCategory $category): array => [ 'id' => (int) $category->id, 'name' => (string) $category->name, ]) ->all(); } public function tagOptions(): array { return NewsTag::query() ->orderBy('name') ->get(['id', 'name']) ->map(fn (NewsTag $tag): array => [ 'id' => (int) $tag->id, 'name' => (string) $tag->name, ]) ->all(); } public function sidebarData(): array { return [ 'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(), 'trending' => NewsArticle::published() ->with('category') ->orderByDesc('views') ->limit(config('news.trending_limit', 5)) ->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']), 'tags' => NewsTag::whereHas('articles', fn ($query) => $query->published())->orderBy('name')->get(), ]; } public function studioListing(array $filters = []): array { $query = NewsArticle::query() ->with(['author:id,username,name', 'category:id,name,slug', 'tags:id,name,slug']) ->editorialOrder(); $status = trim((string) ($filters['status'] ?? '')); $type = trim((string) ($filters['type'] ?? '')); $categoryId = (int) ($filters['category_id'] ?? 0); $search = trim((string) ($filters['q'] ?? '')); $perPage = max(10, min(50, (int) ($filters['per_page'] ?? 15))); if ($status !== '') { $query->where('editorial_status', $status); } if ($type !== '') { $query->where('type', $type); } if ($categoryId > 0) { $query->where('category_id', $categoryId); } if ($search !== '') { $query->where(function (Builder $builder) use ($search): void { $builder->where('title', 'like', '%' . $search . '%') ->orWhere('excerpt', 'like', '%' . $search . '%') ->orWhere('content', 'like', '%' . $search . '%') ->orWhere('meta_title', 'like', '%' . $search . '%'); }); } $paginator = $query->paginate($perPage)->withQueryString(); return [ 'items' => $paginator->getCollection()->map(fn (NewsArticle $article): array => $this->mapStudioListItem($article))->all(), 'meta' => $this->paginationMeta($paginator), 'filters' => [ 'q' => $search, 'status' => $status, 'type' => $type, 'category_id' => $categoryId > 0 ? $categoryId : '', 'per_page' => $perPage, ], ]; } public function mapStudioArticle(NewsArticle $article, ?User $viewer = null): array { $article->loadMissing(['author.profile', 'category', 'tags', 'relatedEntities']); return [ 'id' => (int) $article->id, 'title' => (string) $article->title, 'slug' => (string) $article->slug, 'excerpt' => (string) ($article->excerpt ?? ''), 'content' => (string) ($article->content ?? ''), 'cover_image' => (string) ($article->cover_image ?? ''), 'cover_url' => $article->cover_url, 'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT), 'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT), 'published_at' => \optional($article->published_at)?->toIso8601String(), 'is_featured' => (bool) $article->is_featured, 'is_pinned' => (bool) ($article->is_pinned ?? false), 'comments_enabled' => (bool) ($article->comments_enabled ?? false), 'category_id' => $article->category_id ? (int) $article->category_id : null, 'author_id' => (int) $article->author_id, 'author' => $article->author ? $this->mapUserLookupResult($article->author) : null, 'tag_ids' => $article->tags->pluck('id')->map(fn (mixed $id): int => (int) $id)->all(), 'meta_title' => (string) ($article->meta_title ?? ''), 'meta_description' => (string) ($article->meta_description ?? ''), 'meta_keywords' => (string) ($article->meta_keywords ?? ''), 'canonical_url' => (string) ($article->canonical_url ?? ''), 'og_title' => (string) ($article->og_title ?? ''), 'og_description' => (string) ($article->og_description ?? ''), 'og_image' => (string) ($article->og_image ?? ''), 'relations' => $article->relatedEntities ->map(fn (NewsArticleRelation $relation): array => [ 'entity_type' => (string) $relation->entity_type, 'entity_id' => (int) $relation->entity_id, 'context_label' => (string) ($relation->context_label ?? ''), 'preview' => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer), ]) ->values() ->all(), ]; } public function storeArticle(User $editor, array $data): NewsArticle { $article = new NewsArticle(); $article->author_id = (int) ($data['author_id'] ?? $editor->id); return $this->persistArticle($article, $editor, $data); } public function updateArticle(NewsArticle $article, User $editor, array $data): NewsArticle { return $this->persistArticle($article, $editor, $data); } public function deleteArticle(NewsArticle $article): void { $article->delete(); } public function publish(NewsArticle $article): NewsArticle { $article->forceFill([ 'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, 'status' => 'published', 'published_at' => $article->published_at ?? \now(), ])->save(); return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); } public function archive(NewsArticle $article): NewsArticle { $article->forceFill([ 'editorial_status' => NewsArticle::EDITORIAL_STATUS_ARCHIVED, 'status' => 'draft', ])->save(); return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); } public function toggleFeature(NewsArticle $article): NewsArticle { $article->forceFill(['is_featured' => ! $article->is_featured])->save(); return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); } public function togglePin(NewsArticle $article): NewsArticle { $article->forceFill(['is_pinned' => ! (bool) $article->is_pinned])->save(); return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); } public function searchEntities(string $type, string $query, ?User $viewer = null): array { $type = trim(Str::lower($type)); $query = trim($query); return match ($type) { self::RELATION_GROUP => $this->searchGroups($query, $viewer), self::RELATION_ARTWORK => $this->searchArtworks($query), self::RELATION_COLLECTION => $this->searchCollections($query, $viewer), self::RELATION_RELEASE => $this->searchReleases($query, $viewer), self::RELATION_PROJECT => $this->searchProjects($query, $viewer), self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer), self::RELATION_EVENT => $this->searchEvents($query, $viewer), self::RELATION_USER => $this->searchUsers($query), default => [], }; } public function resolveRelatedEntities(NewsArticle $article, ?User $viewer = null): array { $article->loadMissing('relatedEntities'); return $article->relatedEntities ->map(fn (NewsArticleRelation $relation): ?array => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? ''))) ->filter() ->values() ->all(); } public function syncRelations(NewsArticle $article, array $relations): void { $normalized = \collect($relations) ->map(function (array $relation): ?array { $entityType = trim(Str::lower((string) ($relation['entity_type'] ?? ''))); $entityId = (int) ($relation['entity_id'] ?? 0); if (! array_key_exists($entityType, self::RELATION_LABELS) || $entityId < 1) { return null; } return [ 'entity_type' => $entityType, 'entity_id' => $entityId, 'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''), ]; }) ->filter() ->unique(fn (array $relation): string => $relation['entity_type'] . ':' . $relation['entity_id']) ->values(); $article->relatedEntities()->delete(); foreach ($normalized as $index => $relation) { $article->relatedEntities()->create([ 'entity_type' => $relation['entity_type'], 'entity_id' => $relation['entity_id'], 'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null, 'sort_order' => $index, ]); } } private function persistArticle(NewsArticle $article, User $editor, array $data): NewsArticle { $title = trim((string) ($data['title'] ?? $article->title ?? 'Untitled News Article')); if ($title === '') { $title = 'Untitled News Article'; } $previousCoverImage = trim((string) ($article->cover_image ?? '')); $editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT)); $publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at); $authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id); $article->fill([ 'title' => $title, 'slug' => $this->resolveSlug($title, $article, $data), 'excerpt' => $this->nullableText($data['excerpt'] ?? null), 'content' => (string) ($data['content'] ?? ''), 'cover_image' => $this->nullableText($data['cover_image'] ?? null), 'type' => (string) ($data['type'] ?? NewsArticle::TYPE_ANNOUNCEMENT), 'author_id' => $authorId, 'category_id' => ! empty($data['category_id']) ? (int) $data['category_id'] : null, 'editorial_status' => $editorialStatus, 'status' => $this->legacyStatusFor($editorialStatus), 'published_at' => $publishedAt, 'is_featured' => (bool) ($data['is_featured'] ?? false), 'is_pinned' => (bool) ($data['is_pinned'] ?? false), 'comments_enabled' => (bool) ($data['comments_enabled'] ?? false), 'meta_title' => $this->nullableText($data['meta_title'] ?? null), 'meta_description' => $this->nullableText($data['meta_description'] ?? null), 'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null), 'canonical_url' => $this->nullableText($data['canonical_url'] ?? null), 'og_title' => $this->nullableText($data['og_title'] ?? null), 'og_description' => $this->nullableText($data['og_description'] ?? null), 'og_image' => $this->nullableText($data['og_image'] ?? null), ]); if (! $article->save()) { throw new \RuntimeException('Failed to save NewsArticle.'); } $nextCoverImage = trim((string) ($article->cover_image ?? '')); if ($previousCoverImage !== '' && $previousCoverImage !== $nextCoverImage) { $this->deleteManagedCoverImage($previousCoverImage); } $article->tags()->sync($this->resolveArticleTagIds($data)); $this->syncRelations($article, $data['relations'] ?? []); return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']); } private function mapStudioListItem(NewsArticle $article): array { return [ 'id' => (int) $article->id, 'title' => (string) $article->title, 'slug' => (string) $article->slug, 'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT), 'type_label' => (string) $article->type_label, 'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT), 'published_at' => \optional($article->published_at)?->toIso8601String(), 'cover_url' => $article->cover_url, 'author_name' => (string) ($article->author?->name ?? 'Skinbase'), 'category_name' => (string) ($article->category?->name ?? ''), 'is_featured' => (bool) $article->is_featured, 'is_pinned' => (bool) ($article->is_pinned ?? false), 'views' => (int) $article->views, '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]), 'public_url' => route('news.show', ['slug' => $article->slug]), ]; } private function paginationMeta(LengthAwarePaginator $paginator): array { return [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'from' => $paginator->firstItem(), 'to' => $paginator->lastItem(), ]; } private function resolveSlug(string $title, NewsArticle $article, array $data): string { $requested = trim(Str::slug((string) ($data['slug'] ?? ''))); if ($requested !== '' && $requested !== (string) $article->slug) { return NewsArticle::generateUniqueSlug($requested, $article->exists ? (int) $article->id : null); } if ($article->exists && trim((string) $article->slug) !== '') { return (string) $article->slug; } return NewsArticle::generateUniqueSlug($title, $article->exists ? (int) $article->id : null); } private function normalizeEditorialStatus(string $status): string { return in_array($status, array_column($this->editorialStatusOptions(), 'value'), true) ? $status : NewsArticle::EDITORIAL_STATUS_DRAFT; } private function normalizePublishedAt(string $editorialStatus, mixed $value): ?Carbon { if ($editorialStatus === NewsArticle::EDITORIAL_STATUS_PUBLISHED) { return $value ? Carbon::parse((string) $value) : \now(); } if ($editorialStatus === NewsArticle::EDITORIAL_STATUS_SCHEDULED) { return $value ? Carbon::parse((string) $value) : \now()->addHour(); } if ($value instanceof Carbon) { return $value; } return $value ? Carbon::parse((string) $value) : null; } private function legacyStatusFor(string $editorialStatus): string { return match ($editorialStatus) { NewsArticle::EDITORIAL_STATUS_PUBLISHED => 'published', NewsArticle::EDITORIAL_STATUS_SCHEDULED => 'scheduled', default => 'draft', }; } private function nullableText(mixed $value): ?string { $text = trim((string) ($value ?? '')); 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 { return Group::query() ->with('owner') ->where('visibility', Group::VISIBILITY_PUBLIC) ->when($query !== '', function (Builder $builder) use ($query): void { $builder->where(function (Builder $nested) use ($query): void { $nested->where('name', 'like', '%' . $query . '%') ->orWhere('slug', 'like', '%' . $query . '%') ->orWhere('headline', 'like', '%' . $query . '%'); }); }) ->orderByDesc('followers_count') ->limit(8) ->get() ->map(fn (Group $group): ?array => $this->resolveGroupPreview((int) $group->id, $viewer, '')) ->filter() ->values() ->all(); } private function searchArtworks(string $query): array { return Artwork::query() ->with(['user.profile']) ->where('artwork_status', 'published') ->where('visibility', Artwork::VISIBILITY_PUBLIC) ->when($query !== '', function (Builder $builder) use ($query): void { $builder->where(function (Builder $nested) use ($query): void { $nested->where('title', 'like', '%' . $query . '%') ->orWhere('slug', 'like', '%' . $query . '%') ->orWhere('description', 'like', '%' . $query . '%'); }); }) ->orderByDesc('views') ->limit(8) ->get() ->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, '')) ->filter() ->values() ->all(); } private function searchCollections(string $query, ?User $viewer): array { return Collection::query() ->with(['user', 'coverArtwork']) ->public() ->when($query !== '', function (Builder $builder) use ($query): void { $builder->where(function (Builder $nested) use ($query): void { $nested->where('title', 'like', '%' . $query . '%') ->orWhere('slug', 'like', '%' . $query . '%') ->orWhere('summary', 'like', '%' . $query . '%'); }); }) ->orderByDesc('followers_count') ->limit(8) ->get() ->map(fn (Collection $collection): ?array => $this->resolveCollectionPreview((int) $collection->id, $viewer, '')) ->filter() ->values() ->all(); } private function searchReleases(string $query, ?User $viewer): array { return GroupRelease::query() ->with('group') ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) ->orderByDesc('published_at') ->limit(8) ->get() ->map(fn (GroupRelease $release): ?array => $this->resolveReleasePreview((int) $release->id, $viewer, '')) ->filter() ->values() ->all(); } private function searchProjects(string $query, ?User $viewer): array { return GroupProject::query() ->with('group') ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) ->orderByDesc('updated_at') ->limit(8) ->get() ->map(fn (GroupProject $project): ?array => $this->resolveProjectPreview((int) $project->id, $viewer, '')) ->filter() ->values() ->all(); } private function searchChallenges(string $query, ?User $viewer): array { return GroupChallenge::query() ->with('group') ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) ->orderByDesc('start_at') ->limit(8) ->get() ->map(fn (GroupChallenge $challenge): ?array => $this->resolveChallengePreview((int) $challenge->id, $viewer, '')) ->filter() ->values() ->all(); } private function searchEvents(string $query, ?User $viewer): array { return GroupEvent::query() ->with('group') ->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%')) ->orderByDesc('start_at') ->limit(8) ->get() ->map(fn (GroupEvent $event): ?array => $this->resolveEventPreview((int) $event->id, $viewer, '')) ->filter() ->values() ->all(); } private function searchUsers(string $query): array { return User::query() ->with('profile') ->when($query !== '', function (Builder $builder) use ($query): void { $builder->where(function (Builder $nested) use ($query): void { $nested->where('username', 'like', '%' . $query . '%') ->orWhere('name', 'like', '%' . $query . '%'); }); }) ->orderBy('username') ->limit(8) ->get() ->map(fn (User $user): array => $this->mapUserLookupResult($user)) ->values() ->all(); } private function resolveEntityPreview(string $type, int $entityId, ?User $viewer = null, string $contextLabel = ''): ?array { return match ($type) { self::RELATION_GROUP => $this->resolveGroupPreview($entityId, $viewer, $contextLabel), self::RELATION_ARTWORK => $this->resolveArtworkPreview($entityId, $contextLabel), self::RELATION_COLLECTION => $this->resolveCollectionPreview($entityId, $viewer, $contextLabel), self::RELATION_RELEASE => $this->resolveReleasePreview($entityId, $viewer, $contextLabel), self::RELATION_PROJECT => $this->resolveProjectPreview($entityId, $viewer, $contextLabel), self::RELATION_CHALLENGE => $this->resolveChallengePreview($entityId, $viewer, $contextLabel), self::RELATION_EVENT => $this->resolveEventPreview($entityId, $viewer, $contextLabel), self::RELATION_USER => $this->resolveUserPreview($entityId, $contextLabel), default => null, }; } private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $group = Group::query()->with('owner')->find($entityId); if (! $group || ! $group->canBeViewedBy($viewer)) { return null; } return [ 'id' => (int) $group->id, 'entity_type' => self::RELATION_GROUP, 'entity_label' => self::RELATION_LABELS[self::RELATION_GROUP], 'title' => (string) $group->name, 'subtitle' => '@' . $group->slug, 'description' => Str::limit((string) ($group->headline ?: $group->bio ?: ''), 120), 'url' => $group->publicUrl(), 'image' => $group->bannerUrl(), 'avatar' => $group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Related Group', 'meta' => array_values(array_filter([ (int) $group->artworks_count > 0 ? number_format((int) $group->artworks_count) . ' artworks' : null, (int) $group->followers_count > 0 ? number_format((int) $group->followers_count) . ' followers' : null, ])), ]; } private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array { $artwork = Artwork::query()->with(['user.profile'])->find($entityId); if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) { return null; } return [ 'id' => (int) $artwork->id, 'entity_type' => self::RELATION_ARTWORK, 'entity_label' => self::RELATION_LABELS[self::RELATION_ARTWORK], 'title' => (string) ($artwork->title ?: 'Untitled artwork'), 'subtitle' => $artwork->user?->username ? '@' . $artwork->user->username : null, 'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120), 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]), 'image' => $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md'), 'avatar' => null, 'context_label' => $contextLabel !== '' ? $contextLabel : 'Mentioned artwork', 'meta' => array_values(array_filter([ (int) $artwork->views > 0 ? number_format((int) $artwork->views) . ' views' : null, $artwork->categories()->first()?->name, ])), ]; } private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $collection = Collection::query()->with(['user', 'coverArtwork'])->find($entityId); if (! $collection || ! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) { return null; } return [ 'id' => (int) $collection->id, 'entity_type' => self::RELATION_COLLECTION, 'entity_label' => self::RELATION_LABELS[self::RELATION_COLLECTION], 'title' => (string) $collection->title, 'subtitle' => '@' . $collection->user->username, 'description' => Str::limit((string) ($collection->summary ?: $collection->description ?: ''), 120), 'url' => route('profile.collections.show', ['username' => $collection->user->username, 'slug' => $collection->slug]), 'image' => $collection->coverArtwork?->thumbUrl('lg') ?? $collection->coverArtwork?->thumbUrl('md'), 'avatar' => null, 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured collection', 'meta' => array_values(array_filter([ (int) $collection->artworks_count > 0 ? number_format((int) $collection->artworks_count) . ' items' : null, (int) $collection->followers_count > 0 ? number_format((int) $collection->followers_count) . ' followers' : null, ])), ]; } private function resolveReleasePreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $release = GroupRelease::query()->with('group')->find($entityId); if (! $release || ! $release->group || ! $release->canBeViewedBy($viewer)) { return null; } return [ 'id' => (int) $release->id, 'entity_type' => self::RELATION_RELEASE, 'entity_label' => self::RELATION_LABELS[self::RELATION_RELEASE], 'title' => (string) $release->title, 'subtitle' => (string) $release->group->name, 'description' => Str::limit((string) ($release->summary ?: $release->description ?: ''), 120), 'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]), 'image' => $release->coverUrl(), 'avatar' => $release->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured release', 'meta' => array_values(array_filter([ $release->published_at?->format('d M Y'), Str::headline((string) $release->status), ])), ]; } private function resolveProjectPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $project = GroupProject::query()->with('group')->find($entityId); if (! $project || ! $project->group || ! $project->canBeViewedBy($viewer)) { return null; } return [ 'id' => (int) $project->id, 'entity_type' => self::RELATION_PROJECT, 'entity_label' => self::RELATION_LABELS[self::RELATION_PROJECT], 'title' => (string) $project->title, 'subtitle' => (string) $project->group->name, 'description' => Str::limit((string) ($project->summary ?: $project->description ?: ''), 120), 'url' => route('groups.projects.show', ['group' => $project->group, 'project' => $project]), 'image' => $project->coverUrl(), 'avatar' => $project->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Related project', 'meta' => array_values(array_filter([ Str::headline((string) $project->status), $project->target_date?->format('d M Y'), ])), ]; } private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $challenge = GroupChallenge::query()->with('group')->find($entityId); if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy($viewer)) { return null; } return [ 'id' => (int) $challenge->id, 'entity_type' => self::RELATION_CHALLENGE, 'entity_label' => self::RELATION_LABELS[self::RELATION_CHALLENGE], 'title' => (string) $challenge->title, 'subtitle' => (string) $challenge->group->name, 'description' => Str::limit((string) ($challenge->summary ?: $challenge->description ?: ''), 120), 'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]), 'image' => $challenge->coverUrl(), 'avatar' => $challenge->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Join this challenge', 'meta' => array_values(array_filter([ $challenge->start_at?->format('d M Y'), Str::headline((string) $challenge->status), ])), ]; } private function resolveEventPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $event = GroupEvent::query()->with('group')->find($entityId); if (! $event || ! $event->group || ! $event->canBeViewedBy($viewer)) { return null; } return [ 'id' => (int) $event->id, 'entity_type' => self::RELATION_EVENT, 'entity_label' => self::RELATION_LABELS[self::RELATION_EVENT], 'title' => (string) $event->title, 'subtitle' => (string) $event->group->name, 'description' => Str::limit((string) ($event->summary ?: $event->description ?: ''), 120), 'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]), 'image' => $event->coverUrl(), 'avatar' => $event->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Upcoming event', 'meta' => array_values(array_filter([ $event->start_at?->format('d M Y H:i'), Str::headline((string) $event->event_type), ])), ]; } private function resolveUserPreview(int $entityId, string $contextLabel): ?array { $user = User::query()->with('profile')->find($entityId); if (! $user || trim((string) $user->username) === '') { return null; } return $this->mapUserLookupResult($user, $contextLabel !== '' ? $contextLabel : 'Meet the creator'); } private function mapUserLookupResult(User $user, string $contextLabel = 'Profile'): array { return [ 'id' => (int) $user->id, 'entity_type' => self::RELATION_USER, 'entity_label' => self::RELATION_LABELS[self::RELATION_USER], 'title' => (string) ($user->name ?: $user->username), 'subtitle' => $user->username ? '@' . $user->username : null, 'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120), 'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null, 'image' => null, 'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96), 'context_label' => $contextLabel, 'meta' => [], ]; } }