map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label]) ->values() ->all(); } public function sectionOptions(): array { return collect((array) config('worlds.sections', [])) ->map(fn (array $config, string $key): array => [ 'value' => $key, 'label' => (string) ($config['label'] ?? Str::headline($key)), 'description' => (string) ($config['description'] ?? ''), 'relation_types' => array_values((array) ($config['relation_types'] ?? [])), ]) ->values() ->all(); } public function themeOptions(): array { return collect((array) config('worlds.themes', [])) ->map(fn (array $theme, string $key): array => [ 'value' => $key, 'label' => (string) ($theme['label'] ?? Str::headline($key)), 'accent_color' => $theme['accent_color'] ?? null, 'accent_color_secondary' => $theme['accent_color_secondary'] ?? null, 'background_motif' => $theme['background_motif'] ?? null, 'icon_name' => $theme['icon_name'] ?? null, 'related_tags_json' => array_values(array_map('strval', (array) ($theme['related_tags_json'] ?? []))), 'suggested_badge_label' => (string) ($theme['suggested_badge_label'] ?? ''), 'suggested_cta_label' => (string) ($theme['suggested_cta_label'] ?? ''), ]) ->values() ->all(); } public function studioListing(array $filters = []): array { $query = World::query() ->withCount([ 'worldRelations', 'worldSubmissions as live_submission_count' => fn (Builder $builder): Builder => $builder->where('status', WorldSubmission::STATUS_LIVE), ]) ->orderByDesc('is_active_campaign') ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderByRaw("CASE WHEN status = 'published' THEN 0 WHEN status = 'draft' THEN 1 ELSE 2 END") ->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC') ->orderByDesc('published_at'); $search = trim((string) ($filters['q'] ?? '')); $status = trim((string) ($filters['status'] ?? '')); $type = trim((string) ($filters['type'] ?? '')); $perPage = max(10, min(50, (int) ($filters['per_page'] ?? 15))); if ($search !== '') { $query->where(function (Builder $builder) use ($search): void { $builder->where('title', 'like', '%' . $search . '%') ->orWhere('slug', 'like', '%' . $search . '%') ->orWhere('summary', 'like', '%' . $search . '%') ->orWhere('description', 'like', '%' . $search . '%'); }); } if ($status !== '') { $query->where('status', $status); } if ($type !== '') { $query->where('type', $type); } $paginator = $query->paginate($perPage)->withQueryString(); return [ 'items' => $paginator->getCollection()->map(fn (World $world): array => $this->mapStudioListItem($world))->all(), 'meta' => $this->paginationMeta($paginator), 'filters' => [ 'q' => $search, 'status' => $status, 'type' => $type, 'per_page' => $perPage, ], ]; } public function mapStudioWorld(World $world, ?User $viewer = null): array { $world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes', 'recapArticle.author.profile', 'recapArticle.category']); $canonicalEdition = $this->canonicalEditionForWorld($world); $previousEdition = $this->adjacentEditionForWorld($world, 'previous'); $nextEdition = $this->adjacentEditionForWorld($world, 'next'); $familyEditions = $this->familyEditionsForWorld($world); return [ 'id' => (int) $world->id, 'title' => (string) $world->title, 'slug' => (string) $world->slug, 'tagline' => (string) ($world->tagline ?? ''), 'summary' => (string) ($world->summary ?? ''), 'teaser_title' => (string) ($world->teaser_title ?? ''), 'teaser_summary' => (string) ($world->teaser_summary ?? ''), 'description' => (string) ($world->description ?? ''), 'cover_path' => (string) ($world->cover_path ?? ''), 'cover_url' => $world->coverUrl(), 'teaser_image_path' => (string) ($world->teaser_image_path ?? ''), 'teaser_image_url' => $world->teaserImageUrl(), 'theme_key' => (string) ($world->theme_key ?? ''), 'theme' => $this->themePayload($world), 'accent_color' => (string) ($world->accent_color ?? ''), 'accent_color_secondary' => (string) ($world->accent_color_secondary ?? ''), 'background_motif' => (string) ($world->background_motif ?? ''), 'icon_name' => $this->resolvedIconName($world), 'status' => (string) $world->status, 'type' => (string) $world->type, 'starts_at' => optional($world->starts_at)?->toIso8601String(), 'ends_at' => optional($world->ends_at)?->toIso8601String(), 'promotion_starts_at' => optional($world->promotion_starts_at)?->toIso8601String(), 'promotion_ends_at' => optional($world->promotion_ends_at)?->toIso8601String(), 'accepts_submissions' => (bool) $world->accepts_submissions, 'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED), 'submission_starts_at' => optional($world->submission_starts_at)?->toIso8601String(), 'submission_ends_at' => optional($world->submission_ends_at)?->toIso8601String(), 'submission_note_enabled' => (bool) $world->submission_note_enabled, 'community_section_enabled' => (bool) $world->community_section_enabled, 'allow_readd_after_removal' => (bool) $world->allow_readd_after_removal, 'is_featured' => (bool) $world->is_featured, 'is_active_campaign' => (bool) $world->is_active_campaign, 'is_homepage_featured' => (bool) $world->is_homepage_featured, 'campaign_priority' => $world->campaign_priority, 'is_recurring' => (bool) $world->is_recurring, 'recurrence_key' => (string) ($world->recurrence_key ?? ''), 'recurrence_rule' => (string) ($world->recurrence_rule ?? ''), 'edition_year' => $world->edition_year, 'family_title' => $this->recurrenceFamilyLabel($world), 'family_url' => $this->familyUrlForWorld($world), 'edition_url' => $this->editionUrlForWorld($world), 'is_canonical_edition' => $this->isCanonicalSurfaceWorld($world), 'family_edition_count' => $familyEditions->count(), 'archive_edition_count' => max(0, $familyEditions->count() - 1), 'current_edition' => $canonicalEdition ? $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)) : null, 'previous_edition' => $previousEdition ? $this->mapWorldCard($previousEdition, $this->phaseForWorld($previousEdition)) : null, 'next_edition' => $nextEdition ? $this->mapWorldCard($nextEdition, $this->phaseForWorld($nextEdition)) : null, 'cta_label' => (string) ($world->cta_label ?? ''), 'cta_url' => (string) ($world->cta_url ?? ''), 'badge_label' => (string) ($world->badge_label ?? ''), 'campaign_label' => (string) ($world->campaign_label ?? ''), 'badge_description' => (string) ($world->badge_description ?? ''), 'submission_guidelines' => (string) ($world->submission_guidelines ?? ''), 'badge_url' => (string) ($world->badge_url ?? ''), 'seo_title' => (string) ($world->seo_title ?? ''), 'seo_description' => (string) ($world->seo_description ?? ''), 'og_image_path' => (string) ($world->og_image_path ?? ''), 'og_image_url' => $world->ogImageUrl(), 'published_at' => optional($world->published_at)?->toIso8601String(), 'recap_status' => (string) ($world->recap_status ?: World::RECAP_STATUS_DRAFT), 'recap_status_label' => $this->recapStatusLabel($world), 'recap_title' => (string) ($world->recap_title ?? ''), 'recap_summary' => (string) ($world->recap_summary ?? ''), 'recap_intro' => (string) ($world->recap_intro ?? ''), 'recap_editor_note' => (string) ($world->recap_editor_note ?? ''), 'recap_cover_path' => (string) ($world->recap_cover_path ?? ''), 'recap_cover_url' => $world->recapCoverUrl(), 'recap_article_id' => $world->recap_article_id ? (int) $world->recap_article_id : null, 'recap_article' => $world->recap_article_id ? $this->resolveNewsPreview((int) $world->recap_article_id, 'Recap article') : null, 'recap_published_at' => optional($world->recap_published_at)?->toIso8601String(), 'recap_stats_snapshot' => $world->recap_stats_snapshot_json, 'parent_world_id' => $world->parent_world_id ? (int) $world->parent_world_id : null, 'parent_world' => $world->parentWorld ? [ 'id' => (int) $world->parentWorld->id, 'title' => (string) $world->parentWorld->title, 'slug' => (string) $world->parentWorld->slug, ] : null, 'linked_challenge_id' => $world->linked_challenge_id ? (int) $world->linked_challenge_id : null, 'linked_challenge' => $world->linked_challenge_id ? $this->resolveChallengePreview((int) $world->linked_challenge_id, $viewer, 'Primary challenge') : null, 'show_linked_challenge_section' => (bool) ($world->show_linked_challenge_section ?? true), 'show_linked_challenge_entries' => (bool) ($world->show_linked_challenge_entries ?? true), 'show_linked_challenge_winners' => (bool) ($world->show_linked_challenge_winners ?? true), 'show_linked_challenge_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true), 'auto_grant_challenge_world_rewards' => (bool) ($world->auto_grant_challenge_world_rewards ?? true), 'challenge_teaser_override' => (string) ($world->challenge_teaser_override ?? ''), 'hidden_linked_challenge_artwork_ids_json' => $this->hiddenLinkedChallengeArtworkIds($world), 'related_tags_json' => array_values(array_map('strval', $world->related_tags_json ?? [])), 'section_order_json' => $world->sectionOrder(), 'section_visibility_json' => $world->sectionVisibility(), 'campaign_state' => $this->campaignStateKey($world), 'campaign_state_label' => $this->campaignStateLabel($world), 'promotion_window_label' => $this->promotionWindowLabel($world), 'status_badges' => $this->statusBadges($world, $this->preferredLinkedChallenge($world, $viewer)), 'relations' => $world->worldRelations ->values() ->map(fn (WorldRelation $relation): array => [ 'id' => (int) $relation->id, 'section_key' => (string) $relation->section_key, 'related_type' => (string) $relation->related_type, 'related_id' => (int) $relation->related_id, 'context_label' => (string) ($relation->context_label ?? ''), 'sort_order' => (int) $relation->sort_order, 'is_featured' => (bool) $relation->is_featured, 'preview' => $this->resolveEntityPreview((string) $relation->related_type, (int) $relation->related_id, $viewer, (string) ($relation->context_label ?? '')), ]) ->all(), 'created_by' => $world->createdBy ? [ 'id' => (int) $world->createdBy->id, 'name' => (string) $world->createdBy->name, 'username' => (string) ($world->createdBy->username ?? ''), ] : null, 'submission_review_queue' => $this->submissions->studioReviewQueue($world), 'rewarded_contributors' => $this->rewards->rewardedContributorsForWorld($world), 'analytics' => $this->analytics->studioReport($world), 'urls' => [ 'public' => $world->publicUrl(), 'edit' => route('studio.worlds.edit', ['world' => $world]), 'preview' => route('studio.worlds.preview', ['world' => $world]), 'publish' => route('studio.worlds.publish', ['world' => $world]), 'publish_recap' => route('studio.worlds.recap.publish', ['world' => $world]), 'archive' => route('studio.worlds.archive', ['world' => $world]), 'duplicate' => route('studio.worlds.duplicate', ['world' => $world]), 'new_edition' => route('studio.worlds.new-edition', ['world' => $world]), ], ]; } public function store(User $editor, array $data): World { $world = new World(); return $this->persist($world, $editor, $data); } public function update(World $world, User $editor, array $data): World { return $this->persist($world, $editor, $data); } public function duplicate(World $source, User $editor, bool $asNewEdition = false): World { return $this->duplicateWithMode($source, $editor, $asNewEdition, self::COPY_MODE_WITH_RELATIONS); } public function duplicateWithMode(World $source, User $editor, bool $asNewEdition, string $copyMode): World { if ($asNewEdition && ! $this->canCreateNewEdition($source)) { throw ValidationException::withMessages([ 'recurrence_key' => 'Add recurrence data before creating a new edition.', ]); } $source->loadMissing('worldRelations'); $derivedRecurrenceKey = $this->inferredRecurrenceKey($source); $copyRelations = $copyMode !== self::COPY_MODE_STRUCTURE_ONLY; $preserveRecurrence = $asNewEdition; $data = [ 'title' => $asNewEdition ? $this->nextEditionTitle($source) : $this->duplicateTitle($source), 'slug' => $asNewEdition ? $this->nextEditionSlug($source) : $source->slug . '-copy', 'tagline' => $source->tagline, 'summary' => $source->summary, 'teaser_title' => $source->teaser_title, 'teaser_summary' => $source->teaser_summary, 'description' => $source->description, 'cover_path' => $source->cover_path, 'teaser_image_path' => $source->teaser_image_path, 'theme_key' => $source->theme_key, 'accent_color' => $source->accent_color, 'accent_color_secondary' => $source->accent_color_secondary, 'background_motif' => $source->background_motif, 'icon_name' => $source->icon_name, 'status' => World::STATUS_DRAFT, 'type' => $source->type, 'starts_at' => null, 'ends_at' => null, 'promotion_starts_at' => null, 'promotion_ends_at' => null, 'accepts_submissions' => (bool) $source->accepts_submissions, 'participation_mode' => (string) ($source->participation_mode ?: World::PARTICIPATION_MODE_CLOSED), 'submission_starts_at' => null, 'submission_ends_at' => null, 'submission_note_enabled' => (bool) $source->submission_note_enabled, 'community_section_enabled' => (bool) $source->community_section_enabled, 'allow_readd_after_removal' => (bool) $source->allow_readd_after_removal, 'published_at' => null, 'is_featured' => false, 'is_active_campaign' => false, 'is_homepage_featured' => false, 'campaign_priority' => null, 'is_recurring' => $preserveRecurrence, 'recurrence_key' => $preserveRecurrence ? $derivedRecurrenceKey : null, 'recurrence_rule' => $preserveRecurrence ? $source->recurrence_rule : null, 'edition_year' => $preserveRecurrence ? $this->nextEditionYear($source) : null, 'cta_label' => $source->cta_label, 'cta_url' => $asNewEdition ? null : $source->cta_url, 'badge_label' => $source->badge_label, 'campaign_label' => $source->campaign_label, 'badge_description' => $source->badge_description, 'submission_guidelines' => $source->submission_guidelines, 'badge_url' => $asNewEdition ? null : $source->badge_url, 'seo_title' => $source->seo_title, 'seo_description' => $source->seo_description, 'og_image_path' => $source->og_image_path, 'recap_status' => World::RECAP_STATUS_DRAFT, 'recap_title' => null, 'recap_summary' => null, 'recap_intro' => null, 'recap_editor_note' => null, 'recap_cover_path' => null, 'recap_article_id' => null, 'recap_stats_snapshot_json' => null, 'recap_published_at' => null, 'linked_challenge_id' => $asNewEdition ? null : $source->linked_challenge_id, 'show_linked_challenge_section' => (bool) ($source->show_linked_challenge_section ?? true), 'show_linked_challenge_entries' => (bool) ($source->show_linked_challenge_entries ?? true), 'show_linked_challenge_winners' => (bool) ($source->show_linked_challenge_winners ?? true), 'show_linked_challenge_finalists' => (bool) ($source->show_linked_challenge_finalists ?? true), 'auto_grant_challenge_world_rewards' => (bool) ($source->auto_grant_challenge_world_rewards ?? true), 'challenge_teaser_override' => $asNewEdition ? null : $source->challenge_teaser_override, 'hidden_linked_challenge_artwork_ids_json' => $asNewEdition ? [] : $this->hiddenLinkedChallengeArtworkIds($source), 'related_tags_json' => array_values(array_map('strval', $source->related_tags_json ?? [])), 'section_order_json' => $source->sectionOrder(), 'section_visibility_json' => $source->sectionVisibility(), 'parent_world_id' => $asNewEdition ? (int) ($source->parent_world_id ?: $source->id) : $source->parent_world_id, 'relations' => $copyRelations ? $source->worldRelations ->map(fn (WorldRelation $relation): array => [ 'section_key' => (string) $relation->section_key, 'related_type' => (string) $relation->related_type, 'related_id' => (int) $relation->related_id, 'context_label' => (string) ($relation->context_label ?? ''), 'sort_order' => (int) $relation->sort_order, 'is_featured' => (bool) $relation->is_featured, ]) ->all() : [], ]; return $this->store($editor, $data); } public function duplicateModeOptions(bool $asNewEdition = false): array { return [ [ 'value' => self::COPY_MODE_STRUCTURE_ONLY, 'label' => $asNewEdition ? 'Structure only' : 'Shell only', 'description' => $asNewEdition ? 'Carry the theme, layout, and recurrence family forward without copying curated relations.' : 'Create a fresh draft shell with theme, layout, and metadata but no curated attachments.', ], [ 'value' => self::COPY_MODE_WITH_RELATIONS, 'label' => 'Include curated relations', 'description' => 'Copy the current curated relations as a starting point so the editorial structure is immediately reusable.', ], ]; } public function canCreateNewEdition(World $world): bool { return (bool) ($world->is_recurring || $world->recurrence_key || $world->edition_year); } public function publish(World $world): World { $world->forceFill([ 'status' => World::STATUS_PUBLISHED, 'published_at' => $world->published_at ?? now(), ])->save(); return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group']); } public function publishRecap(World $world): World { if (! $world->isEndedEdition()) { throw ValidationException::withMessages([ 'recap_status' => 'Publish recap after the world has ended or been archived.', ]); } $world->forceFill([ 'recap_status' => World::RECAP_STATUS_PUBLISHED, 'recap_published_at' => $world->recap_published_at ?? now(), 'recap_stats_snapshot_json' => $this->buildRecapStatsSnapshot($world), 'is_active_campaign' => false, 'is_homepage_featured' => false, ])->save(); return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']); } public function archive(World $world): World { $world->forceFill([ 'status' => World::STATUS_ARCHIVED, 'ends_at' => $world->ends_at ?? now(), 'is_active_campaign' => false, 'is_homepage_featured' => false, ])->save(); return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group']); } public function searchEntities(string $type, string $query, ?User $viewer = null): array { $type = trim(Str::lower($type)); $query = trim($query); return match ($type) { WorldRelation::TYPE_ARTWORK => $this->searchArtworks($query), WorldRelation::TYPE_COLLECTION => $this->searchCollections($query, $viewer), WorldRelation::TYPE_USER => $this->searchUsers($query), WorldRelation::TYPE_GROUP => $this->searchGroups($query, $viewer), WorldRelation::TYPE_NEWS => $this->searchNews($query), WorldRelation::TYPE_CHALLENGE => $this->searchChallenges($query, $viewer), WorldRelation::TYPE_EVENT => $this->searchEvents($query, $viewer), WorldRelation::TYPE_RELEASE => $this->searchReleases($query, $viewer), WorldRelation::TYPE_CARD => $this->searchCards($query, $viewer), default => [], }; } public function publicIndexPayload(?User $viewer = null): array { $spotlight = $this->primarySpotlightWorld(); $current = $this->filterCanonicalSurfaceWorlds( $this->currentSurfaceQuery() ->limit(24) ->get() ) ->reject(fn (World $world): bool => $spotlight !== null && (int) $world->id === (int) $spotlight->id) ->take(12) ->values(); $upcoming = $this->filterCanonicalSurfaceWorlds( $this->upcomingSurfaceQuery() ->limit(24) ->get() ) ->take(12) ->values(); $excludedIds = collect([$spotlight?->id]) ->filter() ->merge($current->pluck('id')) ->merge($upcoming->pluck('id')) ->map(fn ($id): int => (int) $id) ->unique() ->values(); $featured = $this->filterCanonicalSurfaceWorlds( $this->featuredSurfaceQuery($excludedIds->all()) ->limit(24) ->get() ) ->take(9) ->values(); $archive = $this->archiveSurfaceQuery()->limit(18)->get(); $recurringFamilies = $this->recurringFamilyIndexPayload(8); $spotlightPayload = $spotlight ? $this->mapWorldCard($spotlight, 'spotlight') : null; return [ 'title' => 'Worlds', 'description' => 'Seasonal, cultural, and editorial campaign spaces that bring together artworks, collections, creators, groups, news, challenges, and releases.', 'spotlightWorld' => $spotlightPayload, 'featuredWorld' => $spotlightPayload, 'activeWorlds' => $current->map(fn (World $world): array => $this->mapWorldCard($world, 'active'))->all(), 'upcomingWorlds' => $upcoming->map(fn (World $world): array => $this->mapWorldCard($world, 'upcoming'))->all(), 'featuredWorlds' => $featured->map(fn (World $world): array => $this->mapWorldCard($world, 'featured'))->all(), 'recurringWorldFamilies' => $recurringFamilies, 'archivedWorlds' => $archive->map(fn (World $world): array => $this->mapWorldCard($world, 'archive'))->all(), 'themeOptions' => $this->themeOptions(), ]; } public function resolvePublicWorld(string $slug): array { $world = World::query() ->publiclyVisible() ->where('slug', $slug) ->first(); if ($world) { $canonicalUrl = $this->canonicalPublicUrl($world); return [ 'world' => $world, 'redirect' => $canonicalUrl !== route('worlds.show', ['world' => $slug]) ? $canonicalUrl : null, ]; } $world = $this->canonicalEditionForRecurrenceKey($slug); return [ 'world' => $world, 'redirect' => null, ]; } public function resolvePublicEdition(string $slug, int $year): array { $world = $this->familyEditionsForRecurrenceKey($slug) ->first(fn (World $edition): bool => (int) ($edition->edition_year ?? 0) === $year) ?? World::query() ->publiclyVisible() ->where('slug', $slug) ->where('edition_year', $year) ->first(); if (! $world) { return [ 'world' => null, 'redirect' => null, ]; } $canonicalUrl = $this->canonicalPublicUrl($world); return [ 'world' => $world, 'redirect' => $canonicalUrl !== route('worlds.editions.show', ['world' => $slug, 'year' => $year]) ? $canonicalUrl : null, ]; } public function canonicalPublicUrl(World $world): string { return $this->publicUrlForWorld($world); } public function publicShowPayload(World $world, ?User $viewer = null, bool $includeDraftRecap = false): array { $world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']); $sections = $this->resolveSections($world, $viewer); $familyEditions = $this->familyEditionsForWorld($world); $canonicalEdition = $this->canonicalEditionForWorld($world); $previousEdition = $this->adjacentEditionForWorld($world, 'previous'); $nextEdition = $this->adjacentEditionForWorld($world, 'next'); $linkedChallenge = $this->preferredLinkedChallenge($world, $viewer); $linkedChallengePanel = $this->linkedChallengePanelPayload($world, $viewer, $linkedChallenge); $linkedChallengeEntries = $linkedChallenge ? $this->linkedChallengeEntriesPayload($world, $linkedChallenge, $viewer) : null; $linkedChallengeWinners = $linkedChallenge ? $this->linkedChallengeWinnersPayload($world, $linkedChallenge, $viewer) : null; $linkedChallengeFinalists = $linkedChallenge ? $this->linkedChallengeFinalistsPayload($world, $linkedChallenge, $viewer) : null; $communitySubmissions = $this->submissions->publicSectionPayload($world, $viewer); $rewardedContributors = $this->rewards->rewardedContributorsForWorld($world); $recap = $this->recapPayload( $world, $sections, $communitySubmissions, $rewardedContributors, $linkedChallengePanel, $linkedChallengeWinners, $linkedChallengeFinalists, $includeDraftRecap, ); if ($recap) { $sections = $this->sectionsAfterRecapExtraction($sections); } $otherEditions = $familyEditions ->reject(fn (World $edition): bool => (int) $edition->id === (int) $world->id) ->values(); $archiveEditions = $otherEditions ->sortBy([ fn (World $edition): int => -1 * (int) ($edition->edition_year ?? 0), fn (World $edition): int => -1 * ($edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0), fn (World $edition): int => -1 * (int) $edition->id, ]) ->values() ->map(fn (World $edition): array => $this->mapWorldCard($edition, 'archive')) ->all(); $relatedWorlds = World::query() ->publiclyVisible() ->where('id', '!=', $world->id) ->when($world->recurrence_key, fn (Builder $builder) => $builder->where(function (Builder $nested) use ($world): void { $nested->where('theme_key', $world->theme_key) ->orWhere('type', $world->type); })->where(function (Builder $nested) use ($world): void { $nested->whereNull('recurrence_key') ->orWhere('recurrence_key', '!=', $world->recurrence_key); })) ->orderByDesc('starts_at') ->limit(3) ->get() ->map(fn (World $item): array => $this->mapWorldCard($item, $item->isCurrent() ? 'active' : 'archive')) ->all(); return [ 'world' => $this->mapWorldDetail($world), 'recap' => $recap, 'sections' => $sections, 'linkedChallenge' => $linkedChallengePanel, 'linkedChallengeEntries' => $linkedChallengeEntries, 'linkedChallengeWinners' => $linkedChallengeWinners, 'linkedChallengeFinalists' => $linkedChallengeFinalists, 'communitySubmissions' => $communitySubmissions, 'rewardedContributors' => $rewardedContributors, 'archiveNotice' => $this->archiveNoticePayload($world, $canonicalEdition), 'currentEdition' => $canonicalEdition && (int) $canonicalEdition->id !== (int) $world->id ? $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)) : null, 'previousEdition' => $previousEdition ? $this->mapWorldCard($previousEdition, $this->phaseForWorld($previousEdition)) : null, 'nextEdition' => $nextEdition ? $this->mapWorldCard($nextEdition, $this->phaseForWorld($nextEdition)) : null, 'archiveEditions' => $archiveEditions, 'familySummary' => $this->mapRecurringFamilySummary($world), 'relatedWorlds' => $relatedWorlds, ]; } public function homepageSpotlight(?User $viewer = null): ?array { $world = $this->primarySpotlightWorld(); if (! $world) { return null; } $world->loadMissing('worldRelations'); $secondary = $this->filterCanonicalSurfaceWorlds( $this->homepageSecondaryQuery((int) $world->id) ->limit(12) ->get() ) ->take(3) ->map(fn (World $item): array => $this->mapWorldCard($item, $item->isActiveCampaign() ? 'active' : 'upcoming')) ->all(); $payload = $this->mapWorldDetail($world); return [ 'primary' => [ 'id' => $payload['id'], 'title' => $payload['title'], 'headline' => $payload['teaser_title'] ?: $payload['title'], 'tagline' => $payload['tagline'], 'summary' => $payload['teaser_summary'] ?: $payload['summary'], 'cover_url' => $payload['teaser_image_url'] ?: $payload['cover_url'], 'icon_name' => $payload['icon_name'], 'badge_label' => $payload['badge_label'], 'campaign_label' => $payload['campaign_label'], 'timeframe_label' => $payload['timeframe_label'], 'promotion_window_label' => $payload['promotion_window_label'], 'theme' => $payload['theme'], 'cta_label' => $payload['cta_label'] ?: ($payload['challenge_cta_label'] ?: 'Explore world'), 'cta_url' => $payload['public_url'], 'public_url' => $payload['public_url'], 'status_badges' => $payload['status_badges'], 'live_submission_count' => $payload['live_submission_count'], 'featured_submission_count' => $payload['featured_submission_count'], 'relation_count' => $payload['relation_count'], 'supporting_item' => $this->resolveHomepageSupportingItem($world, $viewer), ], 'secondary' => $secondary, 'index_url' => route('worlds.index'), ]; } private function recapPayload( World $world, array $sections, ?array $communitySubmissions, array $rewardedContributors, ?array $linkedChallenge, ?array $linkedChallengeWinners, ?array $linkedChallengeFinalists, bool $includeDraftRecap = false, ): ?array { if (! $world->isEndedEdition()) { return null; } $isPreviewDraft = $includeDraftRecap && ! $world->hasPublishedRecap() && $world->hasRecapDraftContent(); if (! $world->hasPublishedRecap() && ! $isPreviewDraft) { return null; } $featuredArtworkSection = collect($sections)->firstWhere('key', 'featured_artworks'); $featuredCreatorSection = collect($sections)->firstWhere('key', 'featured_creators'); $featuredGroupSection = collect($sections)->firstWhere('key', 'featured_groups'); $newsSection = collect($sections)->firstWhere('key', 'news'); $featuredArtworkItems = $this->collectRecapArtworkItems( $featuredArtworkSection, $linkedChallengeWinners, $linkedChallengeFinalists, $communitySubmissions, ); $communityHighlightItems = $this->collectRecapCommunityHighlights($communitySubmissions); $article = $this->recapArticlePayload($world, $linkedChallenge['story'] ?? null, $newsSection); $statsSnapshot = $this->normalizeRecapStatsSnapshot($world->recap_stats_snapshot_json ?: $this->buildRecapStatsSnapshot($world)); $summary = $statsSnapshot['summary'] ?? []; $winnerCount = (int) ($summary['winner_count'] ?? 0); $finalistCount = (int) ($summary['finalist_count'] ?? 0); $rewardGrantCount = (int) ($summary['reward_grants'] ?? 0); $submissionCount = (int) ($summary['submissions'] ?? $summary['live_participations'] ?? 0); return [ 'status' => $isPreviewDraft ? 'draft_preview' : World::RECAP_STATUS_PUBLISHED, 'eyebrow' => $isPreviewDraft ? 'Recap draft preview' : 'Edition recap', 'title' => trim((string) ($world->recap_title ?: ($world->title . ' recap'))), 'summary' => trim((string) ($world->recap_summary ?: ($article['description'] ?? $world->summary ?? ''))), 'intro' => trim((string) ($world->recap_intro ?: $this->defaultRecapIntro($world, $submissionCount, $rewardGrantCount, $winnerCount, $finalistCount))), 'cover_url' => $world->recapCoverUrl(), 'published_at' => optional($world->recap_published_at)?->toIso8601String(), 'article' => $article, 'stats' => [ 'captured_at' => $statsSnapshot['captured_at'] ?? null, 'source' => $world->recap_stats_snapshot_json ? 'snapshot' : 'live', 'items' => $this->recapStatItems($statsSnapshot), ], 'featured_artworks' => [ 'title' => 'Edition highlights', 'description' => 'Curated standouts, synced challenge outcomes, and featured community work that defined this edition.', 'items' => $featuredArtworkItems, ], 'community_highlights' => [ 'title' => 'Community highlights', 'description' => 'Featured community submissions remain visible here so the edition archive keeps its strongest participation in view.', 'items' => $communityHighlightItems, ], 'creators' => [ 'title' => 'Creators and groups', 'description' => 'Featured creators, groups, and rewarded contributors who shaped the atmosphere of this edition.', 'items' => array_slice(array_values(array_merge( array_values((array) ($featuredCreatorSection['items'] ?? [])), array_values((array) ($featuredGroupSection['items'] ?? [])), )), 0, 8), 'rewarded' => array_slice(array_values((array) ($rewardedContributors['items'] ?? [])), 0, 6), ], 'winner_count' => $winnerCount, 'finalist_count' => $finalistCount, ]; } private function sectionsAfterRecapExtraction(array $sections): array { return array_values(array_filter($sections, fn (array $section): bool => ! in_array((string) ($section['key'] ?? ''), [ 'featured_artworks', 'featured_creators', 'featured_groups', 'news', ], true))); } private function recapStatusLabel(World $world): string { return $world->hasPublishedRecap() ? 'Published recap' : 'Draft recap'; } private function recapArticlePayload(World $world, ?array $fallbackStory = null, ?array $newsSection = null): ?array { if ((int) ($world->recap_article_id ?? 0) > 0) { $preview = $this->resolveNewsPreview((int) $world->recap_article_id, 'Recap article'); if ($preview) { return array_merge($preview, [ 'eyebrow' => 'Recap article', 'cta_label' => 'Read full recap', ]); } } if (($fallbackStory['intent'] ?? null) === 'recap') { return array_merge($fallbackStory, [ 'eyebrow' => $fallbackStory['eyebrow'] ?? 'Challenge recap', 'cta_label' => $fallbackStory['cta_label'] ?? 'Read recap', ]); } $newsItem = collect((array) ($newsSection['items'] ?? [])) ->first(fn (array $item): bool => is_string($item['url'] ?? null) && $item['url'] !== ''); if (! $newsItem) { return null; } return array_merge($newsItem, [ 'eyebrow' => 'Related story', 'cta_label' => 'Read story', ]); } private function recapPrimaryCta(World $world, ?array $article): ?array { if (! $world->hasPublishedRecap() || ! $world->isEndedEdition()) { return null; } if ($article && is_string($article['url'] ?? null) && $article['url'] !== '') { return [ 'label' => (string) ($article['cta_label'] ?? 'Read full recap'), 'url' => (string) $article['url'], ]; } return [ 'label' => 'Browse edition highlights', 'url' => $this->publicUrlForWorld($world) . '#world-recap', ]; } private function collectRecapArtworkItems(?array $featuredArtworkSection, ?array $linkedChallengeWinners, ?array $linkedChallengeFinalists, ?array $communitySubmissions): array { return collect(array_merge( array_values((array) ($featuredArtworkSection['items'] ?? [])), array_values((array) ($linkedChallengeWinners['items'] ?? [])), array_values((array) ($linkedChallengeFinalists['items'] ?? [])), array_values(array_filter((array) ($communitySubmissions['items'] ?? []), fn (array $item): bool => (string) ($item['status_label'] ?? '') === 'Featured')), )) ->filter(fn (array $item): bool => (int) ($item['id'] ?? 0) > 0) ->unique('id') ->take(8) ->values() ->all(); } private function collectRecapCommunityHighlights(?array $communitySubmissions): array { $items = collect((array) ($communitySubmissions['items'] ?? [])); if ($items->isEmpty()) { return []; } $featured = $items->filter(fn (array $item): bool => (string) ($item['status_label'] ?? '') === 'Featured'); return ($featured->isNotEmpty() ? $featured : $items) ->take(6) ->values() ->all(); } private function defaultRecapIntro(World $world, int $submissionCount, int $rewardGrantCount, int $winnerCount, int $finalistCount): string { $fragments = array_filter([ $submissionCount > 0 ? number_format($submissionCount) . ' creator submissions' : null, $rewardGrantCount > 0 ? number_format($rewardGrantCount) . ' visible recognitions' : null, $winnerCount > 0 ? number_format($winnerCount) . ' winner' . ($winnerCount === 1 ? '' : 's') : null, $finalistCount > 0 ? number_format($finalistCount) . ' finalist' . ($finalistCount === 1 ? '' : 's') : null, ]); if ($fragments === []) { return 'This edition has moved into the archive, but its strongest moments, contributors, and editorial highlights remain preserved here as a recap.'; } return sprintf( '%s has moved into the archive, and this recap preserves the edition through %s.', (string) $world->title, implode(', ', $fragments), ); } private function buildRecapStatsSnapshot(World $world): array { $analytics = $this->analytics->studioReport($world); $summary = (array) ($analytics['ranges']['all']['summary'] ?? []); $liveSubmissions = (int) $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->count(); $featuredParticipations = (int) $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true)->count(); $rewardGrants = (int) $world->worldRewardGrants()->count(); $winnerCount = (int) $world->worldRewardGrants()->where('reward_type', 'winner')->count(); $finalistCount = (int) $world->worldRewardGrants()->where('reward_type', 'finalist')->count(); $featuredArtworkCount = (int) $world->worldRelations()->where('section_key', 'featured_artworks')->count() + $featuredParticipations; return [ 'captured_at' => now()->toIso8601String(), 'summary' => [ 'views' => (int) ($summary['views'] ?? 0), 'unique_visitors' => (int) ($summary['unique_visitors'] ?? 0), 'submissions' => (int) ($summary['submissions'] ?? $liveSubmissions), 'live_participations' => max($liveSubmissions, (int) ($summary['approved_live_participations'] ?? 0)), 'featured_participations' => max($featuredParticipations, (int) ($summary['featured_participations'] ?? 0)), 'reward_grants' => max($rewardGrants, (int) ($summary['reward_grants'] ?? 0)), 'challenge_clicks' => (int) ($summary['challenge_clicks'] ?? 0), 'winner_count' => $winnerCount, 'finalist_count' => $finalistCount, 'featured_artwork_count' => $featuredArtworkCount, ], ]; } private function recapStatItems(array $snapshot): array { $summary = (array) ($snapshot['summary'] ?? []); return array_values(array_filter([ $this->recapStatItem('views', 'Tracked views', (int) ($summary['views'] ?? 0), 'Public visits recorded across the edition.'), $this->recapStatItem('unique_visitors', 'Unique visitors', (int) ($summary['unique_visitors'] ?? 0), 'Distinct visitors who reached the world page.'), $this->recapStatItem('submissions', 'Submitted works', (int) ($summary['submissions'] ?? 0), 'Creator submissions captured during the campaign.'), $this->recapStatItem('reward_grants', 'Recognitions', (int) ($summary['reward_grants'] ?? 0), 'World rewards and recognitions granted in this edition.'), $this->recapStatItem('featured_artwork_count', 'Highlights surfaced', (int) ($summary['featured_artwork_count'] ?? 0), 'Curated and featured works pulled into the recap.'), $this->recapStatItem('challenge_clicks', 'Challenge follow-through', (int) ($summary['challenge_clicks'] ?? 0), 'Tracked challenge CTA and section interactions.'), ])); } private function recapStatItem(string $key, string $label, int $value, string $description): ?array { if ($value < 1) { return null; } return [ 'key' => $key, 'label' => $label, 'value' => $value, 'description' => $description, ]; } public function navigationCampaign(): ?array { return Cache::remember('worlds.navigation_campaign', 60, function (): ?array { $world = World::query() ->select([ 'id', 'slug', 'title', 'status', 'is_recurring', 'recurrence_key', 'edition_year', 'starts_at', 'ends_at', 'promotion_starts_at', 'promotion_ends_at', 'published_at', 'is_active_campaign', 'is_featured', 'is_homepage_featured', 'campaign_priority', 'campaign_label', ]) ->campaignActive() ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderBy('title') ->limit(12) ->get() ->first(fn (World $candidate): bool => $this->isCanonicalSurfaceWorld($candidate)); if (! $world || ! $world->isActiveCampaign()) { return null; } return [ 'title' => (string) $world->title, 'campaign_label' => (string) ($world->campaign_label ?: 'Live now'), 'status_label' => $this->campaignStateLabel($world), 'url' => $this->publicPathForWorld($world), ]; }); } private function persist(World $world, User $editor, array $data): World { $originalCoverPath = (string) ($world->cover_path ?? ''); $originalTeaserImagePath = (string) ($world->teaser_image_path ?? ''); $originalOgImagePath = (string) ($world->og_image_path ?? ''); $originalRecapCoverPath = (string) ($world->recap_cover_path ?? ''); $title = trim((string) ($data['title'] ?? $world->title ?? 'Untitled World')); $status = (string) ($data['status'] ?? $world->status ?? World::STATUS_DRAFT); $publishedAt = $this->normalizePublishedAt($status, $data['published_at'] ?? $world->published_at); $world->fill([ 'title' => $title, 'slug' => $this->resolveSlug($title, $world, $data), 'tagline' => $this->nullableText($data['tagline'] ?? null), 'summary' => $this->nullableText($data['summary'] ?? null), 'teaser_title' => $this->nullableText($data['teaser_title'] ?? null), 'teaser_summary' => $this->nullableText($data['teaser_summary'] ?? null), 'description' => $this->nullableText($data['description'] ?? null), 'cover_path' => $this->nullableText($data['cover_path'] ?? null), 'teaser_image_path' => $this->nullableText($data['teaser_image_path'] ?? null), 'theme_key' => $this->nullableText($data['theme_key'] ?? null), 'accent_color' => $this->nullableText($data['accent_color'] ?? null), 'accent_color_secondary' => $this->nullableText($data['accent_color_secondary'] ?? null), 'background_motif' => $this->nullableText($data['background_motif'] ?? null), 'icon_name' => $this->nullableText($data['icon_name'] ?? null), 'status' => $status, 'type' => (string) ($data['type'] ?? World::TYPE_SEASONAL), 'starts_at' => ! empty($data['starts_at']) ? Carbon::parse((string) $data['starts_at']) : null, 'ends_at' => ! empty($data['ends_at']) ? Carbon::parse((string) $data['ends_at']) : null, 'promotion_starts_at' => ! empty($data['promotion_starts_at']) ? Carbon::parse((string) $data['promotion_starts_at']) : null, 'promotion_ends_at' => ! empty($data['promotion_ends_at']) ? Carbon::parse((string) $data['promotion_ends_at']) : null, 'accepts_submissions' => (bool) ($data['accepts_submissions'] ?? false), 'participation_mode' => (string) ($data['participation_mode'] ?? ((bool) ($data['accepts_submissions'] ?? false) ? World::PARTICIPATION_MODE_MANUAL_APPROVAL : World::PARTICIPATION_MODE_CLOSED)), 'submission_starts_at' => ! empty($data['submission_starts_at']) ? Carbon::parse((string) $data['submission_starts_at']) : null, 'submission_ends_at' => ! empty($data['submission_ends_at']) ? Carbon::parse((string) $data['submission_ends_at']) : null, 'submission_note_enabled' => (bool) ($data['submission_note_enabled'] ?? true), 'community_section_enabled' => (bool) ($data['community_section_enabled'] ?? true), 'allow_readd_after_removal' => (bool) ($data['allow_readd_after_removal'] ?? true), 'is_featured' => (bool) ($data['is_featured'] ?? false), 'is_active_campaign' => (bool) ($data['is_active_campaign'] ?? false), 'is_homepage_featured' => (bool) ($data['is_homepage_featured'] ?? false), 'campaign_priority' => isset($data['campaign_priority']) && $data['campaign_priority'] !== '' ? (int) $data['campaign_priority'] : null, 'is_recurring' => (bool) ($data['is_recurring'] ?? false), 'recurrence_key' => $this->nullableText($data['recurrence_key'] ?? null), 'recurrence_rule' => $this->nullableText($data['recurrence_rule'] ?? null), 'edition_year' => ! empty($data['edition_year']) ? (int) $data['edition_year'] : null, 'cta_label' => $this->nullableText($data['cta_label'] ?? null), 'cta_url' => $this->nullableText($data['cta_url'] ?? null), 'badge_label' => $this->nullableText($data['badge_label'] ?? null), 'campaign_label' => $this->nullableText($data['campaign_label'] ?? null), 'badge_description' => $this->nullableText($data['badge_description'] ?? null), 'submission_guidelines' => $this->nullableText($data['submission_guidelines'] ?? null), 'badge_url' => $this->nullableText($data['badge_url'] ?? null), 'seo_title' => $this->nullableText($data['seo_title'] ?? null), 'seo_description' => $this->nullableText($data['seo_description'] ?? null), 'og_image_path' => $this->nullableText($data['og_image_path'] ?? null), 'recap_status' => (string) ($data['recap_status'] ?? $world->recap_status ?? World::RECAP_STATUS_DRAFT), 'recap_title' => $this->nullableText($data['recap_title'] ?? null), 'recap_summary' => $this->nullableText($data['recap_summary'] ?? null), 'recap_intro' => $this->nullableText($data['recap_intro'] ?? null), 'recap_editor_note' => $this->nullableText($data['recap_editor_note'] ?? null), 'recap_cover_path' => $this->nullableText($data['recap_cover_path'] ?? null), 'recap_article_id' => $this->normalizeRecapArticleId($data['recap_article_id'] ?? null), 'recap_stats_snapshot_json' => array_key_exists('recap_stats_snapshot_json', $data) ? $this->normalizeRecapStatsSnapshot($data['recap_stats_snapshot_json']) : $world->recap_stats_snapshot_json, 'recap_published_at' => ! empty($data['recap_published_at']) ? Carbon::parse((string) $data['recap_published_at']) : $world->recap_published_at, 'related_tags_json' => collect((array) ($data['related_tags_json'] ?? []))->map(fn ($tag) => trim((string) $tag))->filter()->values()->all(), 'section_order_json' => $this->normalizeSectionOrder($data['section_order_json'] ?? []), 'section_visibility_json' => $this->normalizeSectionVisibility($data['section_visibility_json'] ?? []), 'parent_world_id' => ! empty($data['parent_world_id']) ? (int) $data['parent_world_id'] : null, 'linked_challenge_id' => $this->normalizeLinkedChallengeId($data['linked_challenge_id'] ?? null), 'show_linked_challenge_section' => (bool) ($data['show_linked_challenge_section'] ?? true), 'show_linked_challenge_entries' => (bool) ($data['show_linked_challenge_entries'] ?? true), 'show_linked_challenge_winners' => (bool) ($data['show_linked_challenge_winners'] ?? true), 'show_linked_challenge_finalists' => (bool) ($data['show_linked_challenge_finalists'] ?? true), 'auto_grant_challenge_world_rewards' => (bool) ($data['auto_grant_challenge_world_rewards'] ?? true), 'challenge_teaser_override' => $this->nullableText($data['challenge_teaser_override'] ?? null), 'hidden_linked_challenge_artwork_ids_json' => $this->normalizeArtworkIdList($data['hidden_linked_challenge_artwork_ids_json'] ?? []), 'published_at' => $publishedAt, ]); if (! $world->linked_challenge_id) { $world->challenge_teaser_override = null; $world->hidden_linked_challenge_artwork_ids_json = []; } if ((string) $world->recap_status !== World::RECAP_STATUS_PUBLISHED) { $world->recap_published_at = null; } if ((string) $world->participation_mode === World::PARTICIPATION_MODE_CLOSED) { $world->accepts_submissions = false; } if (! $world->exists) { $world->created_by_user_id = (int) $editor->id; } $world->save(); $this->deleteWorldMediaIfReplaced($originalCoverPath, (string) ($world->cover_path ?? '')); $this->deleteWorldMediaIfReplaced($originalTeaserImagePath, (string) ($world->teaser_image_path ?? '')); $this->deleteWorldMediaIfReplaced($originalOgImagePath, (string) ($world->og_image_path ?? '')); $this->deleteWorldMediaIfReplaced($originalRecapCoverPath, (string) ($world->recap_cover_path ?? '')); $this->syncRelations($world, (array) ($data['relations'] ?? [])); $this->rewards->syncLinkedChallengeRewardsForWorld($world); return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']); } private function syncRelations(World $world, array $relations): void { $normalized = collect($relations) ->map(function (array $relation): ?array { $sectionKey = trim((string) ($relation['section_key'] ?? '')); $relatedType = trim((string) ($relation['related_type'] ?? '')); $relatedId = (int) ($relation['related_id'] ?? 0); if ($sectionKey === '' || $relatedType === '' || $relatedId < 1) { return null; } return [ 'section_key' => $sectionKey, 'related_type' => $relatedType, 'related_id' => $relatedId, 'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''), 'sort_order' => max(0, (int) ($relation['sort_order'] ?? 0)), 'is_featured' => (bool) ($relation['is_featured'] ?? false), ]; }) ->filter() ->sortBy(fn (array $relation): string => sprintf('%s:%09d:%s:%09d', $relation['section_key'], (int) $relation['sort_order'], $relation['related_type'], (int) $relation['related_id'])) ->values(); $world->worldRelations()->delete(); foreach ($normalized as $index => $relation) { $world->worldRelations()->create([ 'section_key' => $relation['section_key'], 'related_type' => $relation['related_type'], 'related_id' => $relation['related_id'], 'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null, 'sort_order' => $relation['sort_order'] ?: $index, 'is_featured' => $relation['is_featured'], ]); } } private function resolveSections(World $world, ?User $viewer = null): array { $relations = $world->worldRelations->values(); if ($relations->isEmpty()) { return []; } $entities = $this->resolveEntitiesForRelations($relations, $viewer); $sectionConfigs = (array) config('worlds.sections', []); $sectionVisibility = $world->sectionVisibility(); $sections = []; foreach ($world->sectionOrder() as $sectionKey) { if (($sectionVisibility[$sectionKey] ?? true) !== true) { continue; } $items = $relations ->where('section_key', $sectionKey) ->map(function (WorldRelation $relation) use ($entities): ?array { $item = $entities->get($this->entityMapKey((string) $relation->related_type, (int) $relation->related_id)); if (! $item) { return null; } return array_merge($item, [ 'context_label' => trim((string) ($relation->context_label ?? '')) !== '' ? (string) $relation->context_label : ($item['context_label'] ?? null), 'is_featured' => (bool) $relation->is_featured, ]); }) ->filter() ->values() ->all(); if ($items === []) { continue; } $sections[] = [ 'key' => $sectionKey, 'title' => (string) ($sectionConfigs[$sectionKey]['label'] ?? Str::headline($sectionKey)), 'description' => (string) ($sectionConfigs[$sectionKey]['description'] ?? ''), 'items' => $items, ]; } return $sections; } private function resolveEntitiesForRelations(SupportCollection $relations, ?User $viewer = null): SupportCollection { $map = collect(); $grouped = $relations->groupBy('related_type'); if ($grouped->has(WorldRelation::TYPE_ARTWORK)) { $ids = $grouped[WorldRelation::TYPE_ARTWORK]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $artworks = Artwork::query() ->with(['user.profile', 'categories.contentType']) ->whereIn('id', $ids) ->where('artwork_status', 'published') ->where('visibility', Artwork::VISIBILITY_PUBLIC) ->get(); foreach ($artworks as $artwork) { $preview = $this->resolveArtworkPreview((int) $artwork->id, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_ARTWORK, (int) $artwork->id), $preview); } } } if ($grouped->has(WorldRelation::TYPE_COLLECTION)) { $ids = $grouped[WorldRelation::TYPE_COLLECTION]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $collections = Collection::query()->with(['user.profile', 'group', 'coverArtwork'])->whereIn('id', $ids)->get(); $payloads = collect($this->collections->mapCollectionCardPayloads($collections, false, $viewer))->keyBy(fn (array $item): int => (int) $item['id']); foreach ($ids as $id) { $payload = $payloads->get($id); if ($payload) { $map->put($this->entityMapKey(WorldRelation::TYPE_COLLECTION, $id), array_merge($payload, [ 'entity_type' => WorldRelation::TYPE_COLLECTION, 'entity_label' => (string) (config('worlds.relation_types.collection') ?? 'Collection'), ])); } } } if ($grouped->has(WorldRelation::TYPE_USER)) { $ids = $grouped[WorldRelation::TYPE_USER]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $users = User::query()->with('profile')->whereIn('id', $ids)->get(); foreach ($users as $user) { $preview = $this->resolveUserPreview((int) $user->id, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_USER, (int) $user->id), $preview); } } } if ($grouped->has(WorldRelation::TYPE_GROUP)) { $ids = $grouped[WorldRelation::TYPE_GROUP]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $groups = Group::query()->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])->whereIn('id', $ids)->get(); foreach ($groups as $group) { if ($group->canBeViewedBy($viewer)) { $map->put($this->entityMapKey(WorldRelation::TYPE_GROUP, (int) $group->id), array_merge($this->groups->mapGroupCard($group, $viewer), [ 'entity_type' => WorldRelation::TYPE_GROUP, 'entity_label' => (string) (config('worlds.relation_types.group') ?? 'Group'), ])); } } } if ($grouped->has(WorldRelation::TYPE_NEWS)) { $ids = $grouped[WorldRelation::TYPE_NEWS]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $articles = NewsArticle::query()->with(['author.profile', 'category'])->published()->whereIn('id', $ids)->get(); foreach ($articles as $article) { $preview = $this->resolveNewsPreview((int) $article->id, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_NEWS, (int) $article->id), $preview); } } } if ($grouped->has(WorldRelation::TYPE_CHALLENGE)) { $ids = $grouped[WorldRelation::TYPE_CHALLENGE]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $items = GroupChallenge::query()->with('group')->whereIn('id', $ids)->get(); foreach ($items as $item) { $preview = $this->resolveChallengePreview((int) $item->id, $viewer, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_CHALLENGE, (int) $item->id), $preview); } } } if ($grouped->has(WorldRelation::TYPE_EVENT)) { $ids = $grouped[WorldRelation::TYPE_EVENT]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $items = GroupEvent::query()->with('group')->whereIn('id', $ids)->get(); foreach ($items as $item) { $preview = $this->resolveEventPreview((int) $item->id, $viewer, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_EVENT, (int) $item->id), $preview); } } } if ($grouped->has(WorldRelation::TYPE_RELEASE)) { $ids = $grouped[WorldRelation::TYPE_RELEASE]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $items = GroupRelease::query()->with('group')->whereIn('id', $ids)->get(); foreach ($items as $item) { $preview = $this->resolveReleasePreview((int) $item->id, $viewer, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_RELEASE, (int) $item->id), $preview); } } } if ($grouped->has(WorldRelation::TYPE_CARD)) { $ids = $grouped[WorldRelation::TYPE_CARD]->pluck('related_id')->map(fn ($id) => (int) $id)->all(); $items = NovaCard::query()->with(['user.profile', 'category'])->whereIn('id', $ids)->get(); foreach ($items as $item) { $preview = $this->resolveCardPreview((int) $item->id, $viewer, ''); if ($preview) { $map->put($this->entityMapKey(WorldRelation::TYPE_CARD, (int) $item->id), $preview); } } } return $map; } private function mapWorldCard(World $world, string $phase): array { $detail = $this->mapWorldDetail($world); return [ 'id' => $detail['id'], 'title' => $detail['title'], 'teaser_title' => $detail['teaser_title'], 'slug' => $detail['slug'], 'tagline' => $detail['tagline'], 'summary' => $detail['teaser_summary'] ?: $detail['summary'], 'cover_url' => $detail['teaser_image_url'] ?: $detail['cover_url'], 'theme' => $detail['theme'], 'type' => $detail['type'], 'status' => $detail['status'], 'phase' => $phase, 'badge_label' => $detail['badge_label'], 'campaign_label' => $detail['campaign_label'], 'icon_name' => $detail['icon_name'], 'timeframe_label' => $detail['timeframe_label'], 'promotion_window_label' => $detail['promotion_window_label'], 'starts_at' => $detail['starts_at'], 'ends_at' => $detail['ends_at'], 'edition_year' => $detail['edition_year'], 'edition_label' => $detail['edition_label'], 'is_recurring' => $detail['is_recurring'], 'family_title' => $detail['family_title'], 'family_url' => $detail['family_url'], 'edition_url' => $detail['edition_url'], 'is_canonical_edition' => $detail['is_canonical_edition'], 'public_url' => $detail['public_url'], 'cta_label' => $detail['cta_label'], 'challenge_cta_label' => $detail['challenge_cta_label'], 'challenge_cta_url' => $detail['challenge_cta_url'], 'is_featured' => $detail['is_featured'], 'is_active_campaign' => $detail['is_active_campaign'], 'is_homepage_featured' => $detail['is_homepage_featured'], 'campaign_state' => $detail['campaign_state'], 'campaign_state_label' => $detail['campaign_state_label'], 'status_badges' => $detail['status_badges'], 'live_submission_count' => $detail['live_submission_count'], 'featured_submission_count' => $detail['featured_submission_count'], 'relation_count' => $detail['relation_count'], ]; } private function mapWorldDetail(World $world): array { $world->loadMissing(['linkedChallenge.group', 'worldRelations', 'recapArticle.author.profile', 'recapArticle.category']); $theme = $this->themePayload($world); $familyTitle = $this->recurrenceFamilyLabel($world); $familyUrl = $this->familyUrlForWorld($world); $editionUrl = $this->editionUrlForWorld($world); $isCanonicalEdition = $this->isCanonicalSurfaceWorld($world); $linkedChallenge = $this->preferredLinkedChallenge($world); $linkedChallengeState = $linkedChallenge ? $this->challengeLifecycleStateForWorld($world, $linkedChallenge) : null; $linkedChallengeStory = $linkedChallenge ? $this->linkedChallengeStoryPayload($world, $linkedChallenge, $linkedChallengeState) : null; $recapArticle = $this->recapArticlePayload($world, $linkedChallengeStory); $recapPrimaryCta = $this->recapPrimaryCta($world, $recapArticle); return [ 'id' => (int) $world->id, 'title' => (string) $world->title, 'slug' => (string) $world->slug, 'tagline' => (string) ($world->tagline ?? ''), 'summary' => (string) ($world->summary ?? ''), 'teaser_title' => (string) ($world->teaser_title ?? ''), 'teaser_summary' => (string) ($world->teaser_summary ?? ''), 'description' => (string) ($world->description ?? ''), 'cover_url' => $world->coverUrl(), 'teaser_image_url' => $world->teaserImageUrl(), 'recap_cover_url' => $world->recapCoverUrl(), 'type' => (string) $world->type, 'status' => (string) $world->status, 'theme' => $theme, 'icon_name' => $this->resolvedIconName($world, $theme), 'badge_label' => (string) ($world->badge_label ?? ''), 'campaign_label' => (string) ($world->campaign_label ?? ''), 'badge_description' => (string) ($world->badge_description ?? ''), 'badge_url' => (string) ($world->badge_url ?? ''), 'cta_label' => $recapPrimaryCta['label'] ?? (string) ($world->cta_label ?? ''), 'challenge_cta_label' => $linkedChallenge ? $this->linkedChallengePrimaryCtaLabel($linkedChallengeState, $linkedChallengeStory) : null, 'challenge_cta_url' => $linkedChallenge ? $this->linkedChallengePrimaryUrl($world, $linkedChallenge, $linkedChallengeState, $linkedChallengeStory) : null, 'cta_url' => $recapPrimaryCta['url'] ?? (string) ($world->cta_url ?? ''), 'starts_at' => optional($world->starts_at)?->toIso8601String(), 'ends_at' => optional($world->ends_at)?->toIso8601String(), 'promotion_starts_at' => optional($world->promotion_starts_at)?->toIso8601String(), 'promotion_ends_at' => optional($world->promotion_ends_at)?->toIso8601String(), 'timeframe_label' => $this->timeframeLabel($world), 'promotion_window_label' => $this->promotionWindowLabel($world), 'related_tags' => array_values(array_map('strval', $world->related_tags_json ?? [])), 'recurrence_key' => (string) ($world->recurrence_key ?? ''), 'edition_year' => $world->edition_year, 'edition_label' => $world->edition_year ? ('Edition ' . $world->edition_year) : null, 'is_recurring' => (bool) $world->is_recurring, 'family_title' => $familyTitle, 'family_slug' => $world->recurrence_key ?: null, 'family_url' => $familyUrl, 'edition_url' => $editionUrl, 'is_canonical_edition' => $isCanonicalEdition, 'is_featured' => (bool) $world->is_featured, 'is_active_campaign' => (bool) $world->is_active_campaign, 'is_homepage_featured' => (bool) $world->is_homepage_featured, 'campaign_priority' => $world->campaign_priority, 'campaign_state' => $this->campaignStateKey($world), 'campaign_state_label' => $this->campaignStateLabel($world), 'status_badges' => $this->statusBadges($world, $linkedChallenge), 'recap_status' => (string) ($world->recap_status ?: World::RECAP_STATUS_DRAFT), 'recap_title' => (string) ($world->recap_title ?? ''), 'recap_summary' => (string) ($world->recap_summary ?? ''), 'recap_cover_url' => $world->recapCoverUrl(), 'recap_published_at' => optional($world->recap_published_at)?->toIso8601String(), 'has_recap' => $world->hasPublishedRecap(), 'recap_article' => $recapArticle, 'live_submission_count' => (int) ($world->live_submission_count ?? $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->count()), 'featured_submission_count' => (int) ($world->featured_submission_count ?? $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true)->count()), 'rewarded_contributor_count' => (int) $world->worldRewardGrants()->count(), 'relation_count' => (int) ($world->world_relations_count ?? $world->worldRelations()->count()), 'public_url' => $this->publicUrlForWorld($world), ]; } private function mapStudioListItem(World $world): array { $linkedChallenge = $this->preferredLinkedChallenge($world); return [ 'id' => (int) $world->id, 'title' => (string) $world->title, 'slug' => (string) $world->slug, 'status' => (string) $world->status, 'type' => (string) $world->type, 'is_recurring' => (bool) $world->is_recurring, 'recurrence_key' => (string) ($world->recurrence_key ?? ''), 'edition_year' => $world->edition_year, 'theme_key' => (string) ($world->theme_key ?? ''), 'summary' => Str::limit(trim(strip_tags((string) ($world->summary ?: $world->description ?: ''))), 120), 'timeframe_label' => $this->timeframeLabel($world), 'promotion_window_label' => $this->promotionWindowLabel($world), 'relation_count' => (int) ($world->world_relations_count ?? 0), 'live_submission_count' => (int) ($world->live_submission_count ?? 0), 'is_featured' => (bool) $world->is_featured, 'is_active_campaign' => (bool) $world->is_active_campaign, 'is_homepage_featured' => (bool) $world->is_homepage_featured, 'campaign_priority' => $world->campaign_priority, 'campaign_state' => $this->campaignStateKey($world), 'campaign_state_label' => $this->campaignStateLabel($world), 'status_badges' => $this->statusBadges($world, $linkedChallenge), 'edit_url' => route('studio.worlds.edit', ['world' => $world]), 'preview_url' => route('studio.worlds.preview', ['world' => $world]), 'public_url' => $this->publicUrlForWorld($world), ]; } private function publicSurfaceQuery(): Builder { return World::query()->with(['linkedChallenge.group'])->withCount([ 'worldRelations', 'worldSubmissions as live_submission_count' => fn (Builder $builder): Builder => $builder->where('status', WorldSubmission::STATUS_LIVE), 'worldSubmissions as featured_submission_count' => fn (Builder $builder): Builder => $builder->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true), ]); } private function currentSurfaceQuery(): Builder { return $this->publicSurfaceQuery() ->current() ->orderByDesc('is_active_campaign') ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderBy('starts_at') ->orderBy('title'); } private function upcomingSurfaceQuery(): Builder { return $this->publicSurfaceQuery() ->published() ->where(function (Builder $builder): void { $builder->where(function (Builder $upcoming): void { $upcoming->whereNotNull('starts_at') ->where('starts_at', '>', now()); })->orWhere(function (Builder $campaign): void { $campaign->where('is_active_campaign', true) ->whereRaw('COALESCE(promotion_starts_at, starts_at) > ?', [now()->toDateTimeString()]); }); }) ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC') ->orderBy('title'); } private function featuredSurfaceQuery(array $excludeIds = []): Builder { return $this->publicSurfaceQuery() ->publiclyVisible() ->where(function (Builder $builder): void { $builder->where('is_featured', true) ->orWhere('is_homepage_featured', true); }) ->when($excludeIds !== [], fn (Builder $builder): Builder => $builder->whereNotIn('id', $excludeIds)) ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderByDesc('published_at'); } private function archiveSurfaceQuery(): Builder { return $this->publicSurfaceQuery() ->archive() ->orderByDesc('ends_at') ->orderByDesc('published_at'); } private function primarySpotlightWorld(): ?World { return $this->firstCanonicalWorld( $this->publicSurfaceQuery() ->campaignActive() ->homepageFeatured() ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderBy('title') ->limit(12) ) ?? $this->firstCanonicalWorld( $this->publicSurfaceQuery() ->campaignActive() ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderBy('title') ->limit(12) ) ?? $this->firstCanonicalWorld( $this->publicSurfaceQuery() ->current() ->where(function (Builder $builder): void { $builder->where('is_featured', true) ->orWhere('is_homepage_featured', true); }) ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderBy('title') ->limit(12) ) ?? $this->firstCanonicalWorld( $this->publicSurfaceQuery() ->campaignUpcoming() ->homepageFeatured() ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC') ->limit(12) ) ?? $this->firstCanonicalWorld( $this->publicSurfaceQuery() ->campaignUpcoming() ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC') ->limit(12) ); } private function homepageSecondaryQuery(int $excludeWorldId): Builder { return $this->publicSurfaceQuery() ->published() ->where('id', '!=', $excludeWorldId) ->where(function (Builder $builder): void { $builder->where(function (Builder $active): void { $active->where('is_active_campaign', true) ->where(function (Builder $window): void { $window->whereRaw('COALESCE(promotion_ends_at, ends_at) IS NULL') ->orWhereRaw('COALESCE(promotion_ends_at, ends_at) >= ?', [now()->toDateTimeString()]); }); })->orWhere(function (Builder $featured): void { $featured->where('is_featured', true) ->current(); }); }) ->orderByDesc('is_active_campaign') ->orderByDesc('is_homepage_featured') ->orderByRaw('COALESCE(campaign_priority, 0) DESC') ->orderByDesc('is_featured') ->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC') ->orderBy('title'); } private function resolveHomepageSupportingItem(World $world, ?User $viewer = null): ?array { $relation = $world->worldRelations ->first(fn (WorldRelation $item): bool => in_array((string) $item->related_type, [WorldRelation::TYPE_CHALLENGE, WorldRelation::TYPE_NEWS, WorldRelation::TYPE_EVENT], true)); if (! $relation) { return null; } return $this->resolveEntityPreview((string) $relation->related_type, (int) $relation->related_id, $viewer, (string) ($relation->context_label ?? '')); } private function firstCanonicalWorld(Builder $query): ?World { return $query ->get() ->first(fn (World $world): bool => $this->isCanonicalSurfaceWorld($world)); } private function campaignStateKey(World $world): string { if ($world->isActiveCampaign()) { return 'live_now'; } if ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) { return 'upcoming'; } if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) { return 'archived'; } if ($world->isCurrent()) { return 'current'; } return (string) $world->status; } private function campaignStateLabel(World $world): string { return match ($this->campaignStateKey($world)) { 'live_now' => 'Live now', 'upcoming' => 'Upcoming', 'archived' => 'Archived', 'current' => 'Current', World::STATUS_PUBLISHED => 'Published', World::STATUS_ARCHIVED => 'Archived', default => 'Draft', }; } private function statusBadges(World $world, ?GroupChallenge $linkedChallenge = null): array { $badges = []; if ($world->isActiveCampaign()) { $badges[] = ['label' => 'Live now', 'tone' => 'emerald']; } elseif ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) { $badges[] = ['label' => 'Upcoming', 'tone' => 'sky']; } elseif ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) { $badges[] = ['label' => 'Archived', 'tone' => 'amber']; } if ($world->isEndingSoon()) { $badges[] = ['label' => 'Ending soon', 'tone' => 'amber']; } if ((bool) $world->is_homepage_featured || (bool) $world->is_featured) { $badges[] = ['label' => 'Featured', 'tone' => 'rose']; } if ($world->hasPublishedRecap()) { $badges[] = ['label' => 'Recap live', 'tone' => 'sky']; } if ($linkedChallenge) { $challengeState = $this->challengeLifecycleStateForWorld($world, $linkedChallenge); if (in_array($challengeState['key'], ['upcoming', 'open', 'voting', 'judging', 'winners_announced'], true)) { $badges[] = ['label' => $challengeState['label'], 'tone' => $challengeState['tone']]; } } return collect($badges) ->unique('label') ->values() ->all(); } public function linkedWorldForChallenge(GroupChallenge $challenge): ?array { $world = $this->firstCanonicalWorld( $this->publicSurfaceQuery() ->publiclyVisible() ->where('linked_challenge_id', (int) $challenge->id) ->orderByDesc('is_active_campaign') ->orderByDesc('published_at') ->limit(12) ); return $world ? $this->mapWorldCard($world, $this->phaseForWorld($world)) : null; } private function preferredLinkedChallenge(World $world, ?User $viewer = null): ?GroupChallenge { $world->loadMissing(['linkedChallenge.group', 'worldRelations']); $linkedChallenge = $world->linkedChallenge; if ($linkedChallenge && $linkedChallenge->group && $linkedChallenge->canBeViewedBy($viewer)) { return $linkedChallenge; } $fallbackChallengeId = $world->worldRelations ->first(fn (WorldRelation $relation): bool => (string) $relation->related_type === WorldRelation::TYPE_CHALLENGE)?->related_id; if (! $fallbackChallengeId) { return null; } $fallback = GroupChallenge::query()->with('group')->find((int) $fallbackChallengeId); if (! $fallback || ! $fallback->group || ! $fallback->canBeViewedBy($viewer)) { return null; } return $fallback; } private function linkedChallengePanelPayload(World $world, ?User $viewer = null, ?GroupChallenge $linkedChallenge = null): ?array { if (! (bool) ($world->show_linked_challenge_section ?? true)) { return null; } $challenge = $linkedChallenge ?? $this->preferredLinkedChallenge($world, $viewer); if (! $challenge) { return null; } $state = $this->challengeLifecycleStateForWorld($world, $challenge); $story = $this->linkedChallengeStoryPayload($world, $challenge, $state); $summary = trim((string) ($world->challenge_teaser_override ?: $challenge->summary ?: $challenge->description ?: '')); return [ 'id' => (int) $challenge->id, 'title' => (string) $challenge->title, 'summary' => $summary !== '' ? Str::limit($summary, 220) : null, 'cover_url' => $challenge->coverUrl(), 'url' => $this->challengeUrl($challenge), 'challenge_url' => $this->challengeUrl($challenge), 'group' => $challenge->group ? [ 'name' => (string) $challenge->group->name, 'url' => $challenge->group->publicUrl(), ] : null, 'status' => (string) $challenge->status, 'state' => $state['key'], 'state_label' => $state['label'], 'state_tone' => $state['tone'], 'cta_label' => $this->linkedChallengePrimaryCtaLabel($state, $story), 'cta_url' => $this->linkedChallengePrimaryUrl($world, $challenge, $state, $story), 'timeframe_label' => $this->challengeTimeframeLabel($challenge), 'entry_count' => (int) ($challenge->artwork_links_count ?? $challenge->artworkLinks()->count()), 'has_winner' => $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->isNotEmpty(), 'show_entries' => (bool) ($world->show_linked_challenge_entries ?? true), 'show_winners' => (bool) ($world->show_linked_challenge_winners ?? true), 'show_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true), 'supports_finalists' => true, 'story' => $story, ]; } private function challengeLifecycleStateForWorld(World $world, GroupChallenge $challenge): array { $state = $this->challengeLifecycleState($challenge); if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) { return [ 'key' => 'closed', 'label' => $state['key'] === 'winners_announced' ? 'Winners announced' : 'Challenge closed', 'tone' => $state['key'] === 'winners_announced' ? 'amber' : 'slate', 'cta_label' => 'View challenge recap', ]; } return $state; } private function linkedChallengeEntriesPayload(World $world, GroupChallenge $challenge, ?User $viewer = null): ?array { if (! (bool) ($world->show_linked_challenge_entries ?? true)) { return null; } $state = $this->challengeLifecycleStateForWorld($world, $challenge); $winnerIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->all(); $finalistIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_FINALIST)->all(); $hiddenArtworkIds = $this->hiddenLinkedChallengeArtworkIds($world); $items = $this->visibleChallengeArtworkQuery($challenge, $viewer) ->when($hiddenArtworkIds !== [], fn (Builder $builder): Builder => $builder->whereNotIn('artworks.id', $hiddenArtworkIds)) ->orderBy('group_challenge_artworks.sort_order') ->limit(12) ->get() ->map(function (Artwork $artwork) use ($state, $winnerIds, $finalistIds): array { $status = 'entry'; if (in_array((int) $artwork->id, $winnerIds, true)) { $status = 'winner'; } elseif (in_array((int) $artwork->id, $finalistIds, true)) { $status = 'finalist'; } return $this->mapLinkedChallengeArtwork($artwork, $status, $state['key']); }) ->all(); if ($items === []) { return null; } return [ 'title' => 'Challenge entries', 'description' => $state['key'] === 'closed' ? 'Entries from the linked challenge remain visible here so the world recap preserves the full field of work.' : 'Entries pulled directly from the linked challenge so the world stays current without duplicating editorial relations.', 'hidden_count' => count($hiddenArtworkIds), 'items' => $items, ]; } private function linkedChallengeWinnersPayload(World $world, GroupChallenge $challenge, ?User $viewer = null): ?array { if (! (bool) ($world->show_linked_challenge_winners ?? true)) { return null; } $state = $this->challengeLifecycleStateForWorld($world, $challenge); $items = $this->linkedChallengeOutcomeItems($challenge, GroupChallengeOutcome::TYPE_WINNER, $viewer, $state['key']); if ($items->isEmpty()) { return null; } return [ 'title' => $items->count() === 1 ? 'Challenge winner' : 'Challenge winners', 'description' => $state['key'] === 'winners_announced' ? 'The linked challenge has published results, and those winners are being carried into the world automatically.' : 'This world is carrying the linked challenge result forward so the campaign recap stays visible here too.', 'item' => $items->first(), 'items' => $items->all(), ]; } private function linkedChallengeFinalistsPayload(World $world, GroupChallenge $challenge, ?User $viewer = null): ?array { if (! (bool) ($world->show_linked_challenge_finalists ?? true)) { return null; } $state = $this->challengeLifecycleStateForWorld($world, $challenge); $items = $this->linkedChallengeOutcomeItems($challenge, GroupChallengeOutcome::TYPE_FINALIST, $viewer, $state['key']); if ($items->isEmpty()) { return null; } return [ 'title' => 'Challenge finalists', 'description' => $state['key'] === 'closed' ? 'Finalists from the linked challenge remain visible here so the archived world keeps the complete recap in view.' : 'Finalists from the linked challenge are being pulled directly into the world so the campaign recap reflects the full result set.', 'items' => $items->all(), ]; } private function linkedChallengeStoryPayload(World $world, GroupChallenge $challenge, ?array $state = null): ?array { $world->loadMissing('worldRelations'); $newsRelations = $world->worldRelations ->where('related_type', WorldRelation::TYPE_NEWS) ->values(); if ($newsRelations->isEmpty()) { return null; } $state = $state ?? $this->challengeLifecycleStateForWorld($world, $challenge); $intent = in_array($state['key'] ?? null, ['winners_announced', 'closed'], true) ? 'recap' : 'announcement'; $candidate = $newsRelations ->map(function (WorldRelation $relation) use ($intent): ?array { $preview = $this->resolveNewsPreview( (int) $relation->related_id, trim((string) ($relation->context_label ?? '')), ); if (! $preview) { return null; } return [ 'preview' => $preview, 'score' => $this->linkedChallengeStoryScore($preview, $relation, $intent), 'sort_key' => sprintf( '%06d:%06d:%09d', 999999 - $this->linkedChallengeStoryScore($preview, $relation, $intent), (bool) $relation->is_featured ? 0 : 1, (int) $relation->sort_order, ), ]; }) ->filter() ->sortBy('sort_key') ->first(); if (! $candidate) { return null; } if ($intent === 'recap' && (int) ($candidate['score'] ?? 0) < 25) { return null; } return array_merge($candidate['preview'], [ 'intent' => $intent, 'eyebrow' => $intent === 'recap' ? 'Challenge recap' : 'Challenge story', 'cta_label' => $intent === 'recap' ? 'Read recap' : 'Read story', ]); } private function linkedChallengeStoryScore(array $preview, WorldRelation $relation, string $intent): int { $haystack = Str::lower(implode(' ', array_filter([ (string) ($relation->context_label ?? ''), (string) ($preview['title'] ?? ''), (string) ($preview['subtitle'] ?? ''), (string) ($preview['description'] ?? ''), ]))); $score = (bool) $relation->is_featured ? 18 : 0; $keywords = $intent === 'recap' ? [ 'recap' => 40, 'results' => 36, 'winner' => 34, 'winners' => 34, 'finalist' => 30, 'finalists' => 30, 'roundup' => 22, 'highlights' => 18, ] : [ 'challenge' => 22, 'announcement' => 20, 'announce' => 20, 'launch' => 18, 'opens' => 16, 'open' => 12, 'submissions' => 14, 'join' => 12, 'call for entries' => 20, ]; foreach ($keywords as $keyword => $weight) { if (Str::contains($haystack, $keyword)) { $score += $weight; } } return $score; } private function linkedChallengePrimaryCtaLabel(?array $state, ?array $story): ?string { if (! $state) { return null; } if (($story['intent'] ?? null) !== 'recap') { return $state['cta_label'] ?? null; } return match ($state['key'] ?? null) { 'winners_announced' => 'Read results recap', 'closed' => 'View challenge recap', default => $state['cta_label'] ?? null, }; } private function linkedChallengePrimaryUrl(World $world, GroupChallenge $challenge, ?array $state = null, ?array $story = null): string { $state = $state ?? $this->challengeLifecycleStateForWorld($world, $challenge); $story = $story ?? $this->linkedChallengeStoryPayload($world, $challenge, $state); if (($story['intent'] ?? null) === 'recap' && in_array($state['key'] ?? null, ['winners_announced', 'closed'], true)) { return (string) $story['url']; } return $this->challengeUrl($challenge); } private function visibleChallengeArtworkQuery(GroupChallenge $challenge, ?User $viewer = null): Builder { $query = Artwork::query() ->select('artworks.*', 'group_challenge_artworks.sort_order as challenge_sort_order') ->join('group_challenge_artworks', function ($join) use ($challenge): void { $join->on('group_challenge_artworks.artwork_id', '=', 'artworks.id') ->where('group_challenge_artworks.group_challenge_id', '=', $challenge->id); }) ->with(['user.profile', 'categories.contentType', 'stats']) ->catalogVisible(); $this->maturity->applyViewerFilter($query, $viewer); return $query; } private function mapLinkedChallengeArtwork(Artwork $artwork, string $status = 'entry', string $stateKey = 'open', ?string $statusLabelOverride = null, ?string $note = null, ?int $position = null): array { $resource = ArtworkListResource::make($artwork)->toArray(request()); $views = (int) ($artwork->stats?->views ?? 0); $statusLabel = $statusLabelOverride ?: match ($status) { 'winner' => $stateKey === 'winners_announced' ? 'Winner announced' : 'Winner', 'finalist' => 'Finalist', 'runner_up' => 'Runner-up', 'honorable_mention' => 'Honorable Mention', 'featured' => 'Featured', default => 'Challenge entry', }; $contextLabel = match ($status) { 'winner' => 'Linked challenge winner', 'finalist' => 'Linked challenge finalist', 'runner_up' => 'Linked challenge runner-up', 'honorable_mention' => 'Linked challenge honorable mention', 'featured' => 'Linked challenge featured entry', default => 'Linked challenge entry', }; return [ 'id' => (int) $artwork->id, 'title' => (string) ($resource['title'] ?? $artwork->title ?? 'Untitled artwork'), 'subtitle' => (string) ($resource['author']['name'] ?? ''), 'description' => Str::limit(trim(strip_tags((string) ($note ?: $artwork->description ?? ''))), 120), 'url' => (string) ($resource['urls']['canonical'] ?? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)])), 'image' => $resource['thumbnail_url'] ?? $artwork->thumbUrl('md'), 'status' => $status, 'status_label' => $statusLabel, 'context_label' => $contextLabel, 'meta' => array_values(array_filter([ $position ? 'Place ' . $position : null, $resource['category']['name'] ?? null, $views > 0 ? number_format($views) . ' views' : null, ])), ]; } private function challengeLifecycleState(GroupChallenge $challenge): array { $now = now(); $status = (string) $challenge->status; $startsAt = $challenge->start_at; $endsAt = $challenge->end_at; $hasWinner = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->isNotEmpty(); if ($status === GroupChallenge::STATUS_DRAFT) { return ['key' => 'draft', 'label' => 'Challenge draft', 'tone' => 'slate', 'cta_label' => 'Preview challenge']; } if ($hasWinner) { return ['key' => 'winners_announced', 'label' => 'Winners announced', 'tone' => 'amber', 'cta_label' => 'See results']; } if ($status === GroupChallenge::STATUS_ARCHIVED) { return ['key' => 'closed', 'label' => 'Challenge closed', 'tone' => 'slate', 'cta_label' => 'View challenge recap']; } if ($status === GroupChallenge::STATUS_ENDED || ($endsAt && $endsAt->isPast())) { if ((string) ($challenge->judging_mode ?? '') === 'community_vote') { return ['key' => 'voting', 'label' => 'Voting live', 'tone' => 'sky', 'cta_label' => 'View entries']; } return ['key' => 'judging', 'label' => 'Judging now', 'tone' => 'violet', 'cta_label' => 'Track challenge']; } if ($startsAt && $startsAt->isFuture()) { return ['key' => 'upcoming', 'label' => 'Challenge upcoming', 'tone' => 'sky', 'cta_label' => 'Challenge opens soon']; } if (in_array($status, [GroupChallenge::STATUS_ACTIVE, GroupChallenge::STATUS_PUBLISHED], true) || ! $startsAt || $startsAt->lte($now)) { return ['key' => 'open', 'label' => 'Challenge open', 'tone' => 'emerald', 'cta_label' => 'Join challenge']; } return ['key' => 'closed', 'label' => 'Challenge closed', 'tone' => 'slate', 'cta_label' => 'View challenge recap']; } private function challengeOutcomeArtworkIds(GroupChallenge $challenge, string $type): SupportCollection { $challenge->loadMissing('outcomes'); $ids = $challenge->outcomes ->where('outcome_type', $type) ->pluck('artwork_id') ->map(fn ($id): int => (int) $id) ->filter(fn (int $id): bool => $id > 0) ->values(); if ($type === GroupChallengeOutcome::TYPE_WINNER && $ids->isEmpty() && (int) ($challenge->featured_artwork_id ?? 0) > 0) { return collect([(int) $challenge->featured_artwork_id]); } return $ids; } private function linkedChallengeOutcomeItems(GroupChallenge $challenge, string $type, ?User $viewer = null, string $stateKey = 'open'): SupportCollection { $artworkIds = $this->challengeOutcomeArtworkIds($challenge, $type); if ($artworkIds->isEmpty()) { return collect(); } $challenge->loadMissing('outcomes'); $artworks = $this->visibleChallengeArtworkQuery($challenge, $viewer) ->whereIn('artworks.id', $artworkIds->all()) ->get() ->keyBy(fn (Artwork $artwork): int => (int) $artwork->id); $outcomes = $challenge->outcomes ->where('outcome_type', $type) ->values(); if ($outcomes->isEmpty() && $type === GroupChallengeOutcome::TYPE_WINNER && (int) ($challenge->featured_artwork_id ?? 0) > 0) { $artwork = $artworks->get((int) $challenge->featured_artwork_id); return $artwork ? collect([$this->mapLinkedChallengeArtwork($artwork, 'winner', $stateKey)]) : collect(); } return $outcomes ->map(function (GroupChallengeOutcome $outcome) use ($artworks, $stateKey, $type): ?array { $artwork = $artworks->get((int) $outcome->artwork_id); if (! $artwork) { return null; } return $this->mapLinkedChallengeArtwork( $artwork, $type, $stateKey, $outcome->title_override, $outcome->note, $outcome->position, ); }) ->filter() ->values(); } private function challengeTimeframeLabel(GroupChallenge $challenge): ?string { if ($challenge->start_at && $challenge->end_at) { return $challenge->start_at->format('M j') . ' - ' . $challenge->end_at->format('M j, Y'); } if ($challenge->start_at) { return 'Starts ' . $challenge->start_at->format('M j, Y'); } if ($challenge->end_at) { return 'Ends ' . $challenge->end_at->format('M j, Y'); } return null; } private function challengeUrl(GroupChallenge $challenge): string { return route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]); } private function normalizeLinkedChallengeId(mixed $value): ?int { $id = (int) $value; return $id > 0 ? $id : null; } private function normalizeArtworkIdList(mixed $value): array { return collect((array) $value) ->map(fn ($id): int => (int) $id) ->filter(fn (int $id): bool => $id > 0) ->unique() ->values() ->all(); } private function hiddenLinkedChallengeArtworkIds(World $world): array { return $this->normalizeArtworkIdList($world->hidden_linked_challenge_artwork_ids_json ?? []); } private function promotionWindowLabel(World $world): ?string { if (! $world->promotion_starts_at && ! $world->promotion_ends_at) { return null; } if ($world->promotion_starts_at && $world->promotion_ends_at) { return 'Promotion ' . $world->promotion_starts_at->format('d M Y') . ' - ' . $world->promotion_ends_at->format('d M Y'); } if ($world->promotion_starts_at) { return 'Promotion starts ' . $world->promotion_starts_at->format('d M Y'); } return 'Promoted through ' . $world->promotion_ends_at?->format('d M Y'); } 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, World $world, array $data): string { $requested = trim(Str::slug((string) ($data['slug'] ?? ''))); $base = $requested !== '' ? $requested : Str::slug($title); $slug = $base !== '' ? $base : 'world'; $counter = 2; while (World::query() ->where('slug', $slug) ->when($world->exists, fn (Builder $builder) => $builder->where('id', '!=', $world->id)) ->exists()) { $slug = Str::limit($base !== '' ? $base : 'world', 170, '') . '-' . $counter; $counter++; } return $slug; } private function normalizePublishedAt(string $status, mixed $value): ?Carbon { if ($status === World::STATUS_PUBLISHED) { return $value ? Carbon::parse((string) $value) : now(); } return $value ? Carbon::parse((string) $value) : null; } private function normalizeRecapArticleId(mixed $value): ?int { $articleId = (int) $value; return $articleId > 0 ? $articleId : null; } private function normalizeRecapStatsSnapshot(mixed $value): ?array { if (! is_array($value)) { return null; } return [ 'captured_at' => is_string($value['captured_at'] ?? null) ? $value['captured_at'] : now()->toIso8601String(), 'summary' => array_map(fn ($item): int => (int) $item, (array) ($value['summary'] ?? [])), ]; } private function normalizeSectionOrder(iterable $sectionOrder): array { $valid = array_keys((array) config('worlds.sections', [])); return collect($sectionOrder) ->map(fn ($key) => trim((string) $key)) ->filter(fn (string $key) => in_array($key, $valid, true)) ->unique() ->values() ->all(); } private function normalizeSectionVisibility(iterable $visibility): array { $valid = array_keys((array) config('worlds.sections', [])); return collect($visibility) ->mapWithKeys(fn ($value, $key): array => [(string) $key => (bool) $value]) ->filter(fn (bool $enabled, string $key): bool => in_array($key, $valid, true)) ->all(); } private function nullableText(mixed $value): ?string { $text = trim((string) ($value ?? '')); return $text === '' ? null : $text; } private function themePayload(World $world): array { $preset = $this->resolvedThemePreset($world); return [ 'key' => $world->theme_key, 'label' => (string) ($preset['label'] ?? Str::headline((string) ($world->theme_key ?: $world->type))), 'accent_color' => $world->accent_color ?: ($preset['accent_color'] ?? '#38bdf8'), 'accent_color_secondary' => $world->accent_color_secondary ?: ($preset['accent_color_secondary'] ?? '#0f172a'), 'background_motif' => $world->background_motif ?: ($preset['background_motif'] ?? 'atmosphere'), 'icon_name' => $this->resolvedIconName($world, $preset), ]; } private function resolvedIconName(World $world, ?array $theme = null): string { $theme ??= $this->resolvedThemePreset($world); $icon = $this->supportedIconName($world->icon_name ?? null); if ($icon !== null) { return $icon; } $themeIcon = $this->supportedIconName($theme['icon_name'] ?? null); if ($themeIcon !== null) { return $themeIcon; } return 'fa-solid fa-globe'; } private function resolvedThemePreset(World $world): array { $themeKey = trim((string) ($world->theme_key ?? '')); if ($themeKey !== '') { $preset = (array) config('worlds.themes.' . $themeKey, []); if ($preset !== []) { return $preset; } } $typeKey = trim((string) $world->type); return (array) config('worlds.themes.' . $typeKey, []); } private function supportedIconName(mixed $icon): ?string { $value = trim((string) ($icon ?? '')); if ($value === '' || ! Str::startsWith($value, 'fa-')) { return null; } return $value; } private function timeframeLabel(World $world): ?string { if ($world->starts_at && $world->ends_at) { return $world->starts_at->format('d M Y') . ' - ' . $world->ends_at->format('d M Y'); } if ($world->starts_at) { return 'Starts ' . $world->starts_at->format('d M Y'); } if ($world->ends_at) { return 'Through ' . $world->ends_at->format('d M Y'); } return $world->edition_year ? 'Edition ' . $world->edition_year : null; } private function familyEditionsForWorld(World $world): SupportCollection { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if ($recurrenceKey === '') { return collect(); } return $this->familyEditionsForRecurrenceKey($recurrenceKey); } private function familyEditionsForRecurrenceKey(string $recurrenceKey): SupportCollection { if (! array_key_exists($recurrenceKey, $this->recurrenceEditionCache)) { $this->recurrenceEditionCache[$recurrenceKey] = $this->publicSurfaceQuery() ->publiclyVisible() ->where('recurrence_key', $recurrenceKey) ->orderByDesc('edition_year') ->orderByDesc('starts_at') ->orderByDesc('published_at') ->get(); } return collect($this->recurrenceEditionCache[$recurrenceKey]); } private function canonicalEditionForWorld(World $world): ?World { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if ($recurrenceKey === '') { return null; } return $this->canonicalEditionForRecurrenceKey($recurrenceKey); } private function canonicalEditionForRecurrenceKey(string $recurrenceKey): ?World { if (! array_key_exists($recurrenceKey, $this->recurrenceCanonicalCache)) { $this->recurrenceCanonicalCache[$recurrenceKey] = $this->selectCanonicalEdition($this->familyEditionsForRecurrenceKey($recurrenceKey)); } return $this->recurrenceCanonicalCache[$recurrenceKey]; } private function selectCanonicalEdition(SupportCollection $editions): ?World { return $editions ->sortBy([ fn (World $edition): int => (string) $edition->status === World::STATUS_PUBLISHED ? 0 : 1, fn (World $edition): int => $edition->isCurrent() ? 0 : 1, fn (World $edition): int => -1 * (int) ($edition->edition_year ?? 0), fn (World $edition): int => -1 * ($edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0), fn (World $edition): int => -1 * (int) $edition->id, ]) ->first(); } private function filterCanonicalSurfaceWorlds(SupportCollection $worlds): SupportCollection { return $worlds ->filter(fn (World $world): bool => $this->isCanonicalSurfaceWorld($world)) ->values(); } private function isCanonicalSurfaceWorld(World $world): bool { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if (! $world->is_recurring || $recurrenceKey === '') { return true; } return (int) ($this->canonicalEditionForRecurrenceKey($recurrenceKey)?->id ?? 0) === (int) $world->id; } private function publicUrlForWorld(World $world): string { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if (! $world->is_recurring || $recurrenceKey === '') { return route('worlds.show', ['world' => $world->slug]); } if ($this->isCanonicalSurfaceWorld($world)) { return route('worlds.show', ['world' => $recurrenceKey]); } if ($world->edition_year !== null) { return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year]); } return route('worlds.show', ['world' => $recurrenceKey]); } private function publicPathForWorld(World $world): string { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if (! $world->is_recurring || $recurrenceKey === '') { return route('worlds.show', ['world' => $world->slug], false); } if ($this->isCanonicalSurfaceWorld($world)) { return route('worlds.show', ['world' => $recurrenceKey], false); } if ($world->edition_year !== null) { return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year], false); } return route('worlds.show', ['world' => $recurrenceKey], false); } private function familyUrlForWorld(World $world): ?string { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); return $recurrenceKey !== '' ? route('worlds.show', ['world' => $recurrenceKey]) : null; } private function editionUrlForWorld(World $world): ?string { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if (! $world->is_recurring || $recurrenceKey === '' || $world->edition_year === null) { return null; } return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year]); } private function recurrenceFamilyLabel(World $world): ?string { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if ($recurrenceKey === '') { return null; } return Str::title(str_replace('-', ' ', $recurrenceKey)); } private function mapRecurringFamilySummary(World $world): ?array { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if ($recurrenceKey === '') { return null; } return $this->buildRecurringFamilySummary($recurrenceKey, $this->familyEditionsForRecurrenceKey($recurrenceKey)); } private function recurringFamilyIndexPayload(int $limit = 8): array { return $this->publicSurfaceQuery() ->publiclyVisible() ->whereNotNull('recurrence_key') ->get() ->filter(fn (World $world): bool => trim((string) ($world->recurrence_key ?? '')) !== '') ->groupBy(fn (World $world): string => (string) $world->recurrence_key) ->map(fn (SupportCollection $editions, string $recurrenceKey): array => $this->buildRecurringFamilySummary($recurrenceKey, $editions)) ->sortBy([ fn (array $family): int => match ((string) ($family['current_world']['campaign_state'] ?? '')) { 'live_now' => 0, 'upcoming' => 1, default => 2, }, fn (array $family): int => -1 * (int) ($family['current_world']['campaign_priority'] ?? 0), fn (array $family): int => -1 * (int) ($family['latest_edition_year'] ?? 0), fn (array $family): string => Str::lower((string) ($family['title'] ?? '')), ]) ->take($limit) ->values() ->all(); } private function buildRecurringFamilySummary(string $recurrenceKey, SupportCollection $editions): array { $canonicalEdition = $this->canonicalEditionForRecurrenceKey($recurrenceKey); $otherEditions = $editions ->reject(fn (World $edition): bool => (int) $edition->id === (int) ($canonicalEdition?->id ?? 0)) ->sortBy([ fn (World $edition): int => -1 * (int) ($edition->edition_year ?? 0), fn (World $edition): int => -1 * ($edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0), fn (World $edition): int => -1 * (int) $edition->id, ]) ->values(); $currentWorld = $canonicalEdition ? $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)) : null; return [ 'id' => 'family-' . $recurrenceKey, 'key' => $recurrenceKey, 'title' => Str::title(str_replace('-', ' ', $recurrenceKey)), 'public_url' => route('worlds.show', ['world' => $recurrenceKey]), 'current_world' => $currentWorld, 'theme' => $currentWorld['theme'] ?? null, 'summary' => $currentWorld['summary'] ?? null, 'tagline' => $currentWorld['tagline'] ?? null, 'cover_url' => $currentWorld['cover_url'] ?? null, 'latest_edition_year' => $canonicalEdition?->edition_year, 'edition_count' => $editions->count(), 'archive_count' => $otherEditions->count(), 'years' => $editions->pluck('edition_year')->filter()->map(fn ($year): int => (int) $year)->unique()->sortDesc()->values()->all(), 'previous_editions' => $otherEditions ->take(3) ->map(fn (World $edition): array => [ 'id' => (int) $edition->id, 'title' => (string) $edition->title, 'edition_year' => $edition->edition_year, 'public_url' => $this->publicUrlForWorld($edition), ]) ->all(), ]; } private function adjacentEditionForWorld(World $world, string $direction): ?World { $familyEditions = $this->familyEditionsForWorld($world); if ($familyEditions->isEmpty() || $world->edition_year === null) { return null; } $currentYear = (int) $world->edition_year; if ($direction === 'previous') { return $familyEditions ->filter(fn (World $edition): bool => $edition->edition_year !== null && (int) $edition->edition_year < $currentYear) ->sortByDesc([ fn (World $edition): int => (int) ($edition->edition_year ?? 0), fn (World $edition): int => $edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0, fn (World $edition): int => (int) $edition->id, ]) ->first(); } return $familyEditions ->filter(fn (World $edition): bool => $edition->edition_year !== null && (int) $edition->edition_year > $currentYear) ->sortBy([ fn (World $edition): int => (int) ($edition->edition_year ?? 0), fn (World $edition): int => $edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0, fn (World $edition): int => (int) $edition->id, ]) ->first(); } private function archiveNoticePayload(World $world, ?World $canonicalEdition): ?array { $familyTitle = $this->recurrenceFamilyLabel($world); if ($familyTitle === null) { return null; } if ($canonicalEdition && (int) $canonicalEdition->id !== (int) $world->id) { return [ 'eyebrow' => 'Archived edition', 'title' => sprintf('You are viewing the %s archived edition of %s.', (string) ($world->edition_year ?? 'previous'), $familyTitle), 'description' => $world->hasPublishedRecap() ? 'Past editions remain public as part of the recurring campaign archive, and this edition now carries a published recap.' : 'Past editions remain public as part of the recurring campaign archive.', 'current_edition' => $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)), ]; } if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) { return [ 'eyebrow' => 'Archive edition', 'title' => sprintf('You are viewing the latest archived edition of %s.', $familyTitle), 'description' => $world->hasPublishedRecap() ? 'No newer public edition is live right now, but this archived edition now preserves its highlights as a published recap.' : 'No newer public edition is live right now, but the family archive remains readable and linked together.', 'current_edition' => null, ]; } return null; } private function phaseForWorld(World $world): string { if ($world->isActiveCampaign()) { return 'active'; } if ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) { return 'upcoming'; } if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) { return 'archive'; } return 'featured'; } private function duplicateTitle(World $world): string { $title = trim((string) $world->title); return $title === '' ? 'World copy' : $title . ' Copy'; } private function nextEditionYear(World $world): int { return max((int) now()->year, (int) ($world->edition_year ?? now()->year)) + 1; } private function nextEditionTitle(World $world): string { $nextYear = $this->nextEditionYear($world); $title = $this->stripTrailingEditionYear(trim((string) $world->title)); if ($title === '') { return 'World ' . $nextYear; } return $title . ' ' . $nextYear; } private function nextEditionSlug(World $world): string { $nextYear = $this->nextEditionYear($world); $slug = $this->inferredRecurrenceKey($world); if ($slug === '') { return 'world-' . $nextYear; } return $slug . '-' . $nextYear; } private function inferredRecurrenceKey(World $world): string { $recurrenceKey = trim((string) ($world->recurrence_key ?? '')); if ($recurrenceKey !== '') { return $recurrenceKey; } $slug = Str::slug($this->stripTrailingEditionYear(trim((string) $world->slug))); if ($slug !== '') { return $slug; } $title = Str::slug($this->stripTrailingEditionYear(trim((string) $world->title))); return $title !== '' ? $title : 'world'; } private function stripTrailingEditionYear(string $value): string { $trimmed = trim($value); if ($trimmed === '') { return ''; } return trim((string) preg_replace('/(?:[\s-]+)(?:19|20)\d{2}$/', '', $trimmed)); } private function searchArtworks(string $query): array { return Artwork::query() ->with(['user.profile', 'group', 'categories.contentType']) ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') ->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 { $like = '%' . $query . '%'; $nested->where('title', 'like', $like) ->orWhere('slug', 'like', $like) ->orWhere('description', 'like', $like) ->orWhereHas('user', function (Builder $userQuery) use ($like): void { $userQuery->where('username', 'like', $like) ->orWhere('name', 'like', $like); }) ->orWhereHas('group', function (Builder $groupQuery) use ($like): void { $groupQuery->where('name', 'like', $like) ->orWhere('slug', 'like', $like); }) ->orWhereHas('categories', function (Builder $categoryQuery) use ($like): void { $categoryQuery->where('name', 'like', $like) ->orWhere('slug', 'like', $like) ->orWhereHas('contentType', function (Builder $contentTypeQuery) use ($like): void { $contentTypeQuery->where('name', 'like', $like) ->orWhere('slug', 'like', $like); }); }); }); }) ->orderByRaw('COALESCE(artwork_stats.views, 0) DESC') ->orderByDesc('artworks.published_at') ->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.profile', '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 searchUsers(string $query): array { return User::query() ->with(['profile', 'statistics']) ->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 . '%'); }); }) ->orderByDesc('nova_featured_creator') ->orderBy('username') ->limit(8) ->get() ->map(fn (User $user): ?array => $this->resolveUserPreview((int) $user->id, '')) ->filter() ->values() ->all(); } private function searchGroups(string $query, ?User $viewer): array { return Group::query() ->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']) ->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 searchNews(string $query): array { return NewsArticle::query() ->with(['author.profile', 'category']) ->published() ->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('excerpt', 'like', '%' . $query . '%'); }); }) ->orderByDesc('published_at') ->limit(8) ->get() ->map(fn (NewsArticle $article): ?array => $this->resolveNewsPreview((int) $article->id, '')) ->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 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 searchCards(string $query, ?User $viewer): array { return NovaCard::query() ->with(['user.profile', 'category']) ->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('published_at') ->limit(8) ->get() ->map(fn (NovaCard $card): ?array => $this->resolveCardPreview((int) $card->id, $viewer, '')) ->filter() ->values() ->all(); } public function resolveEntityPreview(string $type, int $entityId, ?User $viewer = null, string $contextLabel = ''): ?array { return match ($type) { WorldRelation::TYPE_ARTWORK => $this->resolveArtworkPreview($entityId, $contextLabel), WorldRelation::TYPE_COLLECTION => $this->resolveCollectionPreview($entityId, $viewer, $contextLabel), WorldRelation::TYPE_USER => $this->resolveUserPreview($entityId, $contextLabel), WorldRelation::TYPE_GROUP => $this->resolveGroupPreview($entityId, $viewer, $contextLabel), WorldRelation::TYPE_NEWS => $this->resolveNewsPreview($entityId, $contextLabel), WorldRelation::TYPE_CHALLENGE => $this->resolveChallengePreview($entityId, $viewer, $contextLabel), WorldRelation::TYPE_EVENT => $this->resolveEventPreview($entityId, $viewer, $contextLabel), WorldRelation::TYPE_RELEASE => $this->resolveReleasePreview($entityId, $viewer, $contextLabel), WorldRelation::TYPE_CARD => $this->resolveCardPreview($entityId, $viewer, $contextLabel), default => null, }; } public function previewArtwork(Artwork $artwork, string $contextLabel = ''): ?array { if ((string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) { return null; } $artwork->loadMissing(['user.profile', 'categories.contentType', 'stats']); $resource = ArtworkListResource::make($artwork)->toArray(request()); $views = (int) ($artwork->stats?->views ?? 0); return [ 'id' => (int) $artwork->id, 'entity_type' => WorldRelation::TYPE_ARTWORK, 'entity_label' => (string) (config('worlds.relation_types.artwork') ?? 'Artwork'), 'title' => (string) ($resource['title'] ?? 'Untitled artwork'), 'subtitle' => (string) ($resource['author']['name'] ?? ''), 'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120), 'url' => (string) ($resource['urls']['canonical'] ?? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)])), 'image' => $resource['thumbnail_url'] ?? $artwork->thumbUrl('md'), 'avatar' => null, 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured artwork', 'meta' => array_values(array_filter([ $resource['category']['name'] ?? null, $views > 0 ? number_format($views) . ' views' : null, ])), ]; } public function previewCollection(Collection $collection, ?User $viewer = null, string $contextLabel = ''): ?array { $collection->loadMissing(['user.profile', 'group', 'coverArtwork']); if (! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) { return null; } $payload = $this->collections->mapCollectionCardPayloads([$collection], false, $viewer)[0] ?? null; if (! $payload) { return null; } return array_merge($payload, [ 'entity_type' => WorldRelation::TYPE_COLLECTION, 'entity_label' => (string) (config('worlds.relation_types.collection') ?? 'Collection'), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Curated collection', ]); } public function previewUser(User $user, string $contextLabel = ''): ?array { $user->loadMissing(['profile', 'statistics']); if (! $user->username) { return null; } return [ 'id' => (int) $user->id, 'entity_type' => WorldRelation::TYPE_USER, 'entity_label' => (string) (config('worlds.relation_types.user') ?? 'Creator'), 'title' => (string) ($user->name ?: $user->username), 'subtitle' => $user->username ? '@' . $user->username : null, 'description' => Str::limit((string) ($user->profile?->description ?? $user->profile?->about ?? ''), 140), 'url' => route('profile.show', ['username' => strtolower((string) $user->username)]), 'image' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, $user->updated_at?->timestamp) : null, 'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured creator', 'meta' => array_values(array_filter([ $user->nova_featured_creator ? 'Featured creator' : null, (int) $user->followers_count > 0 ? number_format((int) $user->followers_count) . ' followers' : null, ])), ]; } public function previewGroup(Group $group, ?User $viewer = null, string $contextLabel = ''): ?array { $group->loadMissing(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']); if (! $group->canBeViewedBy($viewer)) { return null; } $card = $this->groups->mapGroupCard($group, $viewer); return array_merge($card, [ 'entity_type' => WorldRelation::TYPE_GROUP, 'entity_label' => (string) (config('worlds.relation_types.group') ?? 'Group'), 'title' => (string) ($card['name'] ?? $group->name), 'subtitle' => (string) (($card['owner']['name'] ?? null) ?: 'Group'), 'description' => (string) (($card['bio_excerpt'] ?? null) ?: ($card['headline'] ?? '')), 'url' => route('groups.show', ['group' => $group->slug]), 'image' => $card['banner_url'] ?? null, 'avatar' => $card['avatar_url'] ?? null, 'meta' => array_values(array_filter([ (bool) ($card['is_verified'] ?? false) ? 'Verified group' : null, ((int) data_get($card, 'counts.followers', 0)) > 0 ? number_format((int) data_get($card, 'counts.followers', 0)) . ' followers' : null, ((int) data_get($card, 'counts.artworks', 0)) > 0 ? number_format((int) data_get($card, 'counts.artworks', 0)) . ' artworks' : null, ])), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured group', ]); } public function previewNews(NewsArticle $article, string $contextLabel = ''): ?array { return [ 'id' => (int) $article->id, 'entity_type' => WorldRelation::TYPE_NEWS, 'entity_label' => (string) (config('worlds.relation_types.news') ?? 'News article'), 'title' => (string) $article->title, 'subtitle' => (string) ($article->category?->name ?? 'News'), 'description' => Str::limit((string) ($article->excerpt ?: strip_tags((string) $article->content)), 160), 'url' => route('news.show', ['slug' => $article->slug]), 'image' => $article->cover_url, 'avatar' => $article->author ? AvatarUrl::forUser((int) $article->author->id, $article->author->profile?->avatar_hash, 72) : null, 'context_label' => $contextLabel !== '' ? $contextLabel : 'Related story', 'meta' => array_values(array_filter([ $article->published_at?->format('d M Y'), (string) ($article->type_label ?? ''), ])), ]; } public function previewChallenge(GroupChallenge $challenge, ?User $viewer = null, string $contextLabel = ''): ?array { $challenge->loadMissing(['group', 'outcomes']); if (! $challenge->group || ! $challenge->canBeViewedBy($viewer)) { return null; } $winnerIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->all(); $finalistIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_FINALIST)->all(); $entryPreviewItems = $this->visibleChallengeArtworkQuery($challenge, $viewer) ->orderBy('group_challenge_artworks.sort_order') ->limit(12) ->get() ->map(function (Artwork $artwork) use ($winnerIds, $finalistIds): array { $status = 'entry'; if (in_array((int) $artwork->id, $winnerIds, true)) { $status = 'winner'; } elseif (in_array((int) $artwork->id, $finalistIds, true)) { $status = 'finalist'; } return [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'image' => $artwork->thumbUrl('sm'), 'status' => $status, ]; }) ->all(); return [ 'id' => (int) $challenge->id, 'entity_type' => WorldRelation::TYPE_CHALLENGE, 'entity_label' => (string) (config('worlds.relation_types.challenge') ?? 'Challenge'), 'title' => (string) $challenge->title, 'subtitle' => (string) $challenge->group->name, 'description' => Str::limit((string) ($challenge->summary ?: $challenge->description ?: ''), 140), 'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]), 'image' => $challenge->coverUrl(), 'avatar' => $challenge->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Join the challenge', 'meta' => array_values(array_filter([ $challenge->start_at?->format('d M Y'), Str::headline((string) $challenge->status), ])), 'judging_mode' => (string) ($challenge->judging_mode ?? ''), 'entry_preview_items' => $entryPreviewItems, ]; } private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array { $artwork = Artwork::query()->with(['user.profile', 'categories.contentType', 'stats'])->find($entityId); if (! $artwork) { return null; } return $this->previewArtwork($artwork, $contextLabel); } private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $collection = Collection::query()->with(['user.profile', 'group', 'coverArtwork'])->find($entityId); if (! $collection) { return null; } return $this->previewCollection($collection, $viewer, $contextLabel); } private function resolveUserPreview(int $entityId, string $contextLabel): ?array { $user = User::query()->with(['profile', 'statistics'])->find($entityId); if (! $user) { return null; } return $this->previewUser($user, $contextLabel); } private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $group = Group::query()->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])->find($entityId); if (! $group) { return null; } return $this->previewGroup($group, $viewer, $contextLabel); } private function resolveNewsPreview(int $entityId, string $contextLabel): ?array { $article = NewsArticle::query()->with(['author.profile', 'category'])->published()->find($entityId); if (! $article) { return null; } return $this->previewNews($article, $contextLabel); } private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $challenge = GroupChallenge::query()->with(['group', 'outcomes'])->find($entityId); if (! $challenge) { return null; } return $this->previewChallenge($challenge, $viewer, $contextLabel); } 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' => WorldRelation::TYPE_EVENT, 'entity_label' => (string) (config('worlds.relation_types.event') ?? 'Event'), 'title' => (string) $event->title, 'subtitle' => (string) $event->group->name, 'description' => Str::limit((string) ($event->summary ?: $event->description ?: ''), 140), 'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]), 'image' => $event->coverUrl(), 'avatar' => $event->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Related event', 'meta' => array_values(array_filter([ $event->start_at?->format('d M Y H:i'), $event->location, ])), ]; } 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' => WorldRelation::TYPE_RELEASE, 'entity_label' => (string) (config('worlds.relation_types.release') ?? 'Release'), 'title' => (string) $release->title, 'subtitle' => (string) $release->group->name, 'description' => Str::limit((string) ($release->summary ?: $release->description ?: ''), 140), 'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]), 'image' => $release->coverUrl(), 'avatar' => $release->group->avatarUrl(), 'context_label' => $contextLabel !== '' ? $contextLabel : 'Release spotlight', 'meta' => array_values(array_filter([ $release->published_at?->format('d M Y'), Str::headline((string) $release->status), ])), ]; } private function resolveCardPreview(int $entityId, ?User $viewer, string $contextLabel): ?array { $card = NovaCard::query()->with(['user.profile', 'category'])->find($entityId); if (! $card || ! $card->canBeViewedBy($viewer)) { return null; } return [ 'id' => (int) $card->id, 'entity_type' => WorldRelation::TYPE_CARD, 'entity_label' => (string) (config('worlds.relation_types.card') ?? 'Card'), 'title' => (string) ($card->title ?: 'Untitled card'), 'subtitle' => (string) ($card->category?->name ?? ''), 'description' => Str::limit((string) ($card->description ?? $card->quote_text ?? ''), 140), 'url' => $card->publicUrl(), 'image' => $card->previewUrl(), 'avatar' => $card->user ? AvatarUrl::forUser((int) $card->user->id, $card->user->profile?->avatar_hash, 72) : null, 'context_label' => $contextLabel !== '' ? $contextLabel : 'Themed card', 'meta' => array_values(array_filter([ Str::headline((string) $card->format), (int) $card->likes_count > 0 ? number_format((int) $card->likes_count) . ' likes' : null, ])), ]; } private function entityMapKey(string $type, int $id): string { return $type . ':' . $id; } private function deleteWorldMediaIfReplaced(string $originalPath, string $currentPath): void { $trimmedOriginal = ltrim(trim($originalPath), '/'); $trimmedCurrent = ltrim(trim($currentPath), '/'); if ($trimmedOriginal === '' || $trimmedOriginal === $trimmedCurrent || ! Str::startsWith($trimmedOriginal, 'worlds/media/')) { return; } Storage::disk((string) config('covers.disk', 's3'))->delete($trimmedOriginal); } }