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