Add tests for featured thumbnail generation; apply Pint formatting and related edits

This commit is contained in:
2026-05-06 18:55:40 +02:00
parent 7a8bc8e22a
commit 82f2b1f660
65 changed files with 11325 additions and 49545 deletions

View File

@@ -11,11 +11,15 @@ use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Support\News\NewsCoverImage;
use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle;
@@ -25,6 +29,8 @@ use cPad\Plugins\News\Models\NewsTag;
final class NewsService
{
private const PUBLIC_CACHE_VERSION_KEY = 'news.public.cache.version';
public const RELATION_GROUP = 'group';
public const RELATION_ARTWORK = 'artwork';
public const RELATION_COLLECTION = 'collection';
@@ -45,6 +51,8 @@ final class NewsService
self::RELATION_USER => 'Profile',
];
private ?bool $artworkStatsViewsColumnExists = null;
public function articleTypeOptions(): array
{
return \collect(NewsArticle::TYPE_LABELS)
@@ -98,15 +106,23 @@ final class NewsService
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(),
];
return Cache::remember($this->publicCacheKey('sidebar'), $this->publicCacheTtl(), function (): 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::query()
->whereHas('articles', fn ($query) => $query->published())
->withCount(['articles as published_articles_count' => fn ($query) => $query->published()])
->orderByDesc('published_articles_count')
->orderBy('name')
->limit((int) config('news.sidebar_tags_limit', 18))
->get(),
];
});
}
public function studioListing(array $filters = []): array
@@ -169,6 +185,9 @@ final class NewsService
'content' => (string) ($article->content ?? ''),
'cover_image' => (string) ($article->cover_image ?? ''),
'cover_url' => $article->cover_url,
'cover_mobile_url' => $article->cover_mobile_url,
'cover_desktop_url' => $article->cover_desktop_url,
'cover_srcset' => $article->cover_srcset,
'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT),
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
'published_at' => \optional($article->published_at)?->toIso8601String(),
@@ -214,6 +233,8 @@ final class NewsService
public function deleteArticle(NewsArticle $article): void
{
$article->delete();
$this->invalidatePublicCache();
}
public function publish(NewsArticle $article): NewsArticle
@@ -224,6 +245,8 @@ final class NewsService
'published_at' => $article->published_at ?? \now(),
])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -234,6 +257,8 @@ final class NewsService
'status' => 'draft',
])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -241,6 +266,8 @@ final class NewsService
{
$article->forceFill(['is_featured' => ! $article->is_featured])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -248,6 +275,8 @@ final class NewsService
{
$article->forceFill(['is_pinned' => ! (bool) $article->is_pinned])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -280,6 +309,61 @@ final class NewsService
->all();
}
public function publicArticleShowData(NewsArticle $article, ?User $viewer = null): array
{
if ($viewer !== null) {
return $this->buildPublicArticleShowData($article, $viewer);
}
return Cache::remember(
$this->publicCacheKey('article.show.' . $article->id),
$this->publicCacheTtl(),
fn (): array => $this->buildPublicArticleShowData($article, null),
);
}
private function buildPublicArticleShowData(NewsArticle $article, ?User $viewer = null): array
{
$related = NewsArticle::with('author', 'category')
->published()
->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->editorialOrder()
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
return [
'related' => $related,
'relatedEntities' => $this->resolveRelatedEntities($article, $viewer),
'comments' => $comments,
'commentsCount' => $commentsCount,
];
}
public function invalidatePublicCache(): void
{
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
}
public function syncRelations(NewsArticle $article, array $relations): void
{
$normalized = \collect($relations)
@@ -311,6 +395,23 @@ final class NewsService
'sort_order' => $index,
]);
}
$this->invalidatePublicCache();
}
private function publicCacheKey(string $suffix): string
{
return 'news.public.v' . $this->publicCacheVersion() . '.' . $suffix;
}
private function publicCacheTtl(): int
{
return max(60, (int) config('news.public_cache_ttl', 120));
}
private function publicCacheVersion(): int
{
return (int) Cache::get(self::PUBLIC_CACHE_VERSION_KEY, 1);
}
private function persistArticle(NewsArticle $article, User $editor, array $data): NewsArticle
@@ -362,6 +463,8 @@ final class NewsService
$article->tags()->sync($this->resolveArticleTagIds($data));
$this->syncRelations($article, $data['relations'] ?? []);
$this->invalidatePublicCache();
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
}
@@ -376,6 +479,7 @@ final class NewsService
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
'published_at' => \optional($article->published_at)?->toIso8601String(),
'cover_url' => $article->cover_url,
'cover_srcset' => $article->cover_srcset,
'author_name' => (string) ($article->author?->name ?? 'Skinbase'),
'category_name' => (string) ($article->category?->name ?? ''),
'is_featured' => (bool) $article->is_featured,
@@ -485,13 +589,13 @@ final class NewsService
private function deleteManagedCoverImage(string $path): void
{
$trimmed = ltrim(trim($path), '/');
$paths = NewsCoverImage::managedPaths($path);
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
if ($paths === []) {
return;
}
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($trimmed);
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($paths);
}
private function searchGroups(string $query, ?User $viewer): array
@@ -517,8 +621,9 @@ final class NewsService
private function searchArtworks(string $query): array
{
return Artwork::query()
$queryBuilder = Artwork::query()
->with(['user.profile'])
->select('artworks.*')
->where('artwork_status', 'published')
->where('visibility', Artwork::VISIBILITY_PUBLIC)
->when($query !== '', function (Builder $builder) use ($query): void {
@@ -528,8 +633,17 @@ final class NewsService
->orWhere('description', 'like', '%' . $query . '%');
});
})
->orderByDesc('views')
->limit(8)
->limit(8);
if ($this->artworkStatsViewsAvailable()) {
$queryBuilder->leftJoin('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
->orderByRaw('COALESCE(stats.views, 0) DESC');
} else {
$queryBuilder->orderByDesc('published_at');
}
return $queryBuilder
->orderByDesc('artworks.id')
->get()
->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, ''))
->filter()
@@ -537,6 +651,16 @@ final class NewsService
->all();
}
private function artworkStatsViewsAvailable(): bool
{
if ($this->artworkStatsViewsColumnExists === null) {
$this->artworkStatsViewsColumnExists = Schema::hasTable('artwork_stats')
&& Schema::hasColumn('artwork_stats', 'views');
}
return $this->artworkStatsViewsColumnExists;
}
private function searchCollections(string $query, ?User $viewer): array
{
return Collection::query()