Files
SkinbaseNova/app/Services/Worlds/WorldService.php
2026-04-18 17:02:56 +02:00

1369 lines
62 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Worlds;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupRelease;
use App\Models\NovaCard;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Services\CollectionService;
use App\Services\GroupCardService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle;
final class WorldService
{
public function __construct(
private readonly CollectionService $collections,
private readonly GroupCardService $groups,
private readonly WorldSubmissionService $submissions,
) {
}
public function relationTypeOptions(): array
{
return collect((array) config('worlds.relation_types', []))
->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')->orderByDesc('is_featured')->orderByRaw("CASE WHEN status = 'published' THEN 0 WHEN status = 'draft' THEN 1 ELSE 2 END")->orderByDesc('starts_at')->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']);
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'tagline' => (string) ($world->tagline ?? ''),
'summary' => (string) ($world->summary ?? ''),
'description' => (string) ($world->description ?? ''),
'cover_path' => (string) ($world->cover_path ?? ''),
'cover_url' => $world->coverUrl(),
'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(),
'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_recurring' => (bool) $world->is_recurring,
'recurrence_key' => (string) ($world->recurrence_key ?? ''),
'recurrence_rule' => (string) ($world->recurrence_rule ?? ''),
'edition_year' => $world->edition_year,
'cta_label' => (string) ($world->cta_label ?? ''),
'cta_url' => (string) ($world->cta_url ?? ''),
'badge_label' => (string) ($world->badge_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(),
'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,
'related_tags_json' => array_values(array_map('strval', $world->related_tags_json ?? [])),
'section_order_json' => $world->sectionOrder(),
'section_visibility_json' => $world->sectionVisibility(),
'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),
'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]),
'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
{
$source->loadMissing('worldRelations');
$data = [
'title' => $asNewEdition ? $this->nextEditionTitle($source) : $this->duplicateTitle($source),
'slug' => $asNewEdition ? $this->nextEditionSlug($source) : $source->slug . '-copy',
'tagline' => $source->tagline,
'summary' => $source->summary,
'description' => $source->description,
'cover_path' => $source->cover_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,
'accepts_submissions' => (bool) $source->accepts_submissions,
'participation_mode' => (string) ($source->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
'submission_starts_at' => $source->submission_starts_at,
'submission_ends_at' => $source->submission_ends_at,
'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_recurring' => $asNewEdition ? true : $source->is_recurring,
'recurrence_key' => $asNewEdition ? ($source->recurrence_key ?: Str::slug((string) $source->slug)) : $source->recurrence_key,
'recurrence_rule' => $source->recurrence_rule,
'edition_year' => $asNewEdition ? $this->nextEditionYear($source) : $source->edition_year,
'cta_label' => $source->cta_label,
'cta_url' => $source->cta_url,
'badge_label' => $source->badge_label,
'badge_description' => $source->badge_description,
'submission_guidelines' => $source->submission_guidelines,
'badge_url' => $source->badge_url,
'seo_title' => $source->seo_title,
'seo_description' => $source->seo_description,
'og_image_path' => $source->og_image_path,
'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' => $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 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']);
}
public function archive(World $world): World
{
$world->forceFill([
'status' => World::STATUS_ARCHIVED,
'ends_at' => $world->ends_at ?? now(),
])->save();
return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations']);
}
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
{
$featuredWorld = World::query()->current()->where('is_featured', true)->latest('starts_at')->first()
?? World::query()->published()->where('is_featured', true)->latest('published_at')->first();
$current = World::query()->current()->orderByDesc('is_featured')->orderBy('starts_at')->limit(12)->get();
$upcoming = World::query()->upcoming()->orderBy('starts_at')->limit(12)->get();
$archive = World::query()->archive()->orderByDesc('ends_at')->orderByDesc('published_at')->limit(18)->get();
return [
'title' => 'Worlds',
'description' => 'Seasonal, cultural, and editorial campaign spaces that bring together artworks, collections, creators, groups, news, challenges, and releases.',
'featuredWorld' => $featuredWorld ? $this->mapWorldCard($featuredWorld, 'featured') : null,
'activeWorlds' => $current->map(fn (World $world): array => $this->mapWorldCard($world, 'active'))->all(),
'upcomingWorlds' => $upcoming->map(fn (World $world): array => $this->mapWorldCard($world, 'upcoming'))->all(),
'archivedWorlds' => $archive->map(fn (World $world): array => $this->mapWorldCard($world, 'archive'))->all(),
'themeOptions' => $this->themeOptions(),
];
}
public function publicShowPayload(World $world, ?User $viewer = null): array
{
$world->loadMissing(['createdBy.profile', 'parentWorld', 'archiveEditions', 'worldRelations']);
$sections = $this->resolveSections($world, $viewer);
$archiveEditions = $world->archiveEditions
->filter(fn (World $edition): bool => $edition->isPubliclyVisible())
->sortByDesc(fn (World $edition): int => (int) ($edition->edition_year ?? 0))
->values()
->map(fn (World $edition): array => $this->mapWorldCard($edition, 'archive'))
->all();
$relatedWorlds = World::query()
->publiclyVisible()
->when($world->recurrence_key, fn (Builder $builder) => $builder->where('recurrence_key', $world->recurrence_key))
->where('id', '!=', $world->id)
->orderByDesc('starts_at')
->limit(3)
->get()
->map(fn (World $item): array => $this->mapWorldCard($item, $item->isCurrent() ? 'active' : 'archive'))
->all();
return [
'world' => $this->mapWorldDetail($world),
'sections' => $sections,
'communitySubmissions' => $this->submissions->publicSectionPayload($world, $viewer),
'archiveEditions' => $archiveEditions,
'relatedWorlds' => $relatedWorlds,
];
}
public function homepageSpotlight(?User $viewer = null): ?array
{
$world = World::query()->current()->where('is_featured', true)->latest('starts_at')->first()
?? World::query()->current()->latest('starts_at')->first()
?? World::query()->upcoming()->where('is_featured', true)->orderBy('starts_at')->first();
if (! $world) {
return null;
}
$payload = $this->mapWorldDetail($world);
return [
'id' => $payload['id'],
'title' => $payload['title'],
'tagline' => $payload['tagline'],
'summary' => $payload['summary'],
'cover_url' => $payload['cover_url'],
'icon_name' => $payload['icon_name'],
'badge_label' => $payload['badge_label'],
'timeframe_label' => $payload['timeframe_label'],
'theme' => $payload['theme'],
'cta_label' => $payload['cta_label'] ?: 'Explore world',
'cta_url' => $payload['public_url'],
'public_url' => $payload['public_url'],
];
}
private function persist(World $world, User $editor, array $data): World
{
$originalCoverPath = (string) ($world->cover_path ?? '');
$originalOgImagePath = (string) ($world->og_image_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),
'description' => $this->nullableText($data['description'] ?? null),
'cover_path' => $this->nullableText($data['cover_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,
'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_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),
'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),
'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,
'published_at' => $publishedAt,
]);
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($originalOgImagePath, (string) ($world->og_image_path ?? ''));
$this->syncRelations($world, (array) ($data['relations'] ?? []));
return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations']);
}
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'],
'slug' => $detail['slug'],
'tagline' => $detail['tagline'],
'summary' => $detail['summary'],
'cover_url' => $detail['cover_url'],
'theme' => $detail['theme'],
'type' => $detail['type'],
'status' => $detail['status'],
'phase' => $phase,
'badge_label' => $detail['badge_label'],
'icon_name' => $detail['icon_name'],
'timeframe_label' => $detail['timeframe_label'],
'starts_at' => $detail['starts_at'],
'ends_at' => $detail['ends_at'],
'public_url' => $detail['public_url'],
'cta_label' => $detail['cta_label'],
'is_featured' => $detail['is_featured'],
];
}
private function mapWorldDetail(World $world): array
{
$theme = $this->themePayload($world);
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'tagline' => (string) ($world->tagline ?? ''),
'summary' => (string) ($world->summary ?? ''),
'description' => (string) ($world->description ?? ''),
'cover_url' => $world->coverUrl(),
'type' => (string) $world->type,
'status' => (string) $world->status,
'theme' => $theme,
'icon_name' => $this->resolvedIconName($world, $theme),
'badge_label' => (string) ($world->badge_label ?? ''),
'badge_description' => (string) ($world->badge_description ?? ''),
'badge_url' => (string) ($world->badge_url ?? ''),
'cta_label' => (string) ($world->cta_label ?? ''),
'cta_url' => (string) ($world->cta_url ?? ''),
'starts_at' => optional($world->starts_at)?->toIso8601String(),
'ends_at' => optional($world->ends_at)?->toIso8601String(),
'timeframe_label' => $this->timeframeLabel($world),
'related_tags' => array_values(array_map('strval', $world->related_tags_json ?? [])),
'recurrence_key' => (string) ($world->recurrence_key ?? ''),
'edition_year' => $world->edition_year,
'is_recurring' => (bool) $world->is_recurring,
'is_featured' => (bool) $world->is_featured,
'public_url' => $world->publicUrl(),
];
}
private function mapStudioListItem(World $world): array
{
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'status' => (string) $world->status,
'type' => (string) $world->type,
'theme_key' => (string) ($world->theme_key ?? ''),
'summary' => Str::limit(trim(strip_tags((string) ($world->summary ?: $world->description ?: ''))), 120),
'timeframe_label' => $this->timeframeLabel($world),
'relation_count' => (int) ($world->world_relations_count ?? 0),
'is_featured' => (bool) $world->is_featured,
'edit_url' => route('studio.worlds.edit', ['world' => $world]),
'preview_url' => route('studio.worlds.preview', ['world' => $world]),
'public_url' => $world->publicUrl(),
];
}
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 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 = (array) config('worlds.themes.' . $world->theme_key, []);
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 ??= (array) config('worlds.themes.' . $world->theme_key, []);
$icon = trim((string) ($world->icon_name ?? ''));
if ($icon !== '') {
return $icon;
}
$themeIcon = trim((string) ($theme['icon_name'] ?? ''));
if ($themeIcon !== '') {
return $themeIcon;
}
return 'fa-solid fa-sparkles';
}
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 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 = trim((string) $world->title);
if ($title === '') {
return 'World ' . $nextYear;
}
if ($world->edition_year && str_contains($title, (string) $world->edition_year)) {
return str_replace((string) $world->edition_year, (string) $nextYear, $title);
}
return $title . ' ' . $nextYear;
}
private function nextEditionSlug(World $world): string
{
$nextYear = $this->nextEditionYear($world);
$slug = trim((string) $world->slug);
if ($slug === '') {
return 'world-' . $nextYear;
}
if ($world->edition_year && str_contains($slug, (string) $world->edition_year)) {
return str_replace((string) $world->edition_year, (string) $nextYear, $slug);
}
return $slug . '-' . $nextYear;
}
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,
};
}
private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array
{
$artwork = Artwork::query()->with(['user.profile', 'categories.contentType', 'stats'])->find($entityId);
if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) {
return null;
}
$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,
])),
];
}
private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$collection = Collection::query()->with(['user.profile', 'group', 'coverArtwork'])->find($entityId);
if (! $collection || ! $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',
]);
}
private function resolveUserPreview(int $entityId, string $contextLabel): ?array
{
$user = User::query()->with(['profile', 'statistics'])->find($entityId);
if (! $user || ! $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,
])),
];
}
private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$group = Group::query()->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])->find($entityId);
if (! $group || ! $group->canBeViewedBy($viewer)) {
return null;
}
return array_merge($this->groups->mapGroupCard($group, $viewer), [
'entity_type' => WorldRelation::TYPE_GROUP,
'entity_label' => (string) (config('worlds.relation_types.group') ?? 'Group'),
'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured group',
]);
}
private function resolveNewsPreview(int $entityId, string $contextLabel): ?array
{
$article = NewsArticle::query()->with(['author.profile', 'category'])->published()->find($entityId);
if (! $article) {
return null;
}
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 ?? ''),
])),
];
}
private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$challenge = GroupChallenge::query()->with('group')->find($entityId);
if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy($viewer)) {
return null;
}
return [
'id' => (int) $challenge->id,
'entity_type' => 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),
])),
];
}
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);
}
}