minor fixes
This commit is contained in:
@@ -9,6 +9,7 @@ use App\Models\Tag;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
||||
@@ -19,6 +20,7 @@ final class ArtworkSearchService
|
||||
{
|
||||
private const BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
@@ -82,22 +84,46 @@ final class ArtworkSearchService
|
||||
/**
|
||||
* Load artworks for a tag page, sorted by views + likes descending.
|
||||
*/
|
||||
public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator
|
||||
public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
if (! $tag) {
|
||||
return $this->emptyPaginator($perPage);
|
||||
}
|
||||
|
||||
$cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1);
|
||||
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
|
||||
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->with(['user.profile', 'categories.contentType']);
|
||||
|
||||
match ($sort) {
|
||||
'likes' => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
'latest' => $query
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id'),
|
||||
'downloads' => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
default => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
};
|
||||
|
||||
return $query
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,12 +151,12 @@ final class ArtworkSearchService
|
||||
* Used by categoryPageSort() and contentTypePageSort().
|
||||
*/
|
||||
private const CATEGORY_SORT_FIELDS = [
|
||||
'trending' => ['trending_score_24h:desc', 'created_at:desc'],
|
||||
'fresh' => ['created_at:desc'],
|
||||
'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'fresh' => ['published_at_ts:desc'],
|
||||
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
|
||||
'oldest' => ['created_at:asc'],
|
||||
'oldest' => ['published_at_ts:asc'],
|
||||
];
|
||||
|
||||
/** Cache TTL (seconds) per sort alias for category pages. */
|
||||
@@ -237,7 +263,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent artworks by created_at.
|
||||
* Most recent artworks by publish timestamp.
|
||||
*/
|
||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
@@ -245,7 +271,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -294,7 +320,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -310,7 +336,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -378,7 +404,7 @@ final class ArtworkSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in given categories, sorted by created_at desc.
|
||||
* Fresh artworks in given categories, sorted by publish timestamp desc.
|
||||
* Used for personalized "Fresh in your favourite categories" section.
|
||||
*
|
||||
* @param string[] $categorySlugs
|
||||
@@ -400,7 +426,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||
'sort' => ['created_at:desc'],
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,24 @@ class ArtworkService
|
||||
{
|
||||
protected int $cacheTtl = 3600; // seconds
|
||||
|
||||
/**
|
||||
* Lightweight relations needed to render browse/list cards.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private function browseRelations(): array
|
||||
{
|
||||
return [
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_url',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared browse query used by /browse, content-type pages, and category pages.
|
||||
*/
|
||||
@@ -30,13 +48,7 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
]);
|
||||
->with($this->browseRelations());
|
||||
|
||||
$normalizedSort = strtolower(trim($sort));
|
||||
if ($normalizedSort === 'oldest') {
|
||||
@@ -110,6 +122,7 @@ class ArtworkService
|
||||
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
||||
{
|
||||
$query = Artwork::public()->published()
|
||||
->with($this->browseRelations())
|
||||
->whereHas('categories', function ($q) use ($category) {
|
||||
$q->where('categories.id', $category->id);
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ final class AdaptiveTimeWindow
|
||||
{
|
||||
$ttl = (int) config('early_growth.cache_ttl.time_window', 600);
|
||||
|
||||
return Cache::remember('egs.uploads_per_day', $ttl, function (): float {
|
||||
return (float) Cache::remember('egs.uploads_per_day', $ttl, function (): float {
|
||||
$count = Artwork::query()
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
@@ -72,7 +72,7 @@ final class AdaptiveTimeWindow
|
||||
->where('published_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
return round($count / 7, 2);
|
||||
return (float) round($count / 7, 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,15 @@ final class HomepageService
|
||||
}
|
||||
|
||||
public function getHomepageGroups(?\App\Models\User $viewer = null): array
|
||||
{
|
||||
if (! $viewer) {
|
||||
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
|
||||
}
|
||||
|
||||
return $this->buildHomepageGroups($viewer);
|
||||
}
|
||||
|
||||
private function buildHomepageGroups(?\App\Models\User $viewer = null): array
|
||||
{
|
||||
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
|
||||
$spotlight = $featured[0] ?? null;
|
||||
@@ -314,6 +323,10 @@ final class HomepageService
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
|
||||
if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
|
||||
return $this->getRisingLowSignalFromDb($limit);
|
||||
}
|
||||
|
||||
return $items
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
@@ -348,6 +361,26 @@ final class HomepageService
|
||||
->all();
|
||||
}
|
||||
|
||||
private function getRisingLowSignalFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
|
||||
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
|
||||
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
|
||||
})
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||
->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||||
*
|
||||
@@ -466,26 +499,38 @@ final class HomepageService
|
||||
try {
|
||||
$since = now()->subWeek();
|
||||
|
||||
$rows = DB::table('artworks')
|
||||
->join('users as u', 'u.id', '=', 'artworks.user_id')
|
||||
$weeklyUploads = Artwork::query()
|
||||
->selectRaw('user_id, COUNT(*) as weekly_uploads')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->where('published_at', '>=', $since)
|
||||
->groupBy('user_id');
|
||||
|
||||
$rows = DB::table('users as u')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
|
||||
$join->on('weekly_uploads.user_id', '=', 'u.id');
|
||||
})
|
||||
->select(
|
||||
'u.id',
|
||||
'u.name',
|
||||
'u.username',
|
||||
'up.avatar_hash',
|
||||
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
|
||||
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
|
||||
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
|
||||
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
|
||||
DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
|
||||
DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
|
||||
DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
|
||||
DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
|
||||
)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->whereNotNull('artworks.published_at')
|
||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
|
||||
->whereNull('u.deleted_at')
|
||||
->where('u.is_active', true)
|
||||
->where(function ($query): void {
|
||||
$query->where('us.uploads_count', '>', 0)
|
||||
->orWhere('weekly_uploads.weekly_uploads', '>', 0);
|
||||
})
|
||||
->orderByDesc('weekly_uploads')
|
||||
->orderByDesc('total_awards')
|
||||
->orderByDesc('total_views')
|
||||
@@ -494,18 +539,23 @@ final class HomepageService
|
||||
|
||||
$userIds = $rows->pluck('id')->all();
|
||||
|
||||
// Pick one random artwork thumbnail per creator for the card background.
|
||||
$thumbsByUser = Artwork::public()
|
||||
$latestArtworkIds = Artwork::public()
|
||||
->published()
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('hash')
|
||||
->whereNotNull('thumb_ext')
|
||||
->inRandomOrder()
|
||||
->selectRaw('MAX(id) as id')
|
||||
->groupBy('user_id')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$thumbsByUser = Artwork::query()
|
||||
->whereIn('id', $latestArtworkIds)
|
||||
->get(['id', 'user_id', 'hash', 'thumb_ext'])
|
||||
->groupBy('user_id');
|
||||
->keyBy('user_id');
|
||||
|
||||
return $rows->map(function ($u) use ($thumbsByUser) {
|
||||
$artworkForBg = $thumbsByUser->get($u->id)?->first();
|
||||
$artworkForBg = $thumbsByUser->get($u->id);
|
||||
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
|
||||
|
||||
return [
|
||||
@@ -792,6 +842,37 @@ final class HomepageService
|
||||
return $artworks;
|
||||
}
|
||||
|
||||
private function collectionHasNoRisingMomentum(Collection $items): bool
|
||||
{
|
||||
if ($items->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $items->every(function ($item): bool {
|
||||
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
|
||||
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
|
||||
|
||||
return $heat <= 0.0 && $velocity <= 0.0;
|
||||
});
|
||||
}
|
||||
|
||||
private function risingRecentActivitySubquery()
|
||||
{
|
||||
$since = now()->startOfHour()->subHours(24);
|
||||
|
||||
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
|
||||
->selectRaw('rising_snapshots.artwork_id')
|
||||
->selectRaw('(
|
||||
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
|
||||
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
|
||||
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
|
||||
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
|
||||
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
|
||||
) as recent_signal_24h')
|
||||
->where('rising_snapshots.bucket_hour', '>=', $since)
|
||||
->groupBy('rising_snapshots.artwork_id');
|
||||
}
|
||||
|
||||
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||||
{
|
||||
$thumbMd = $artwork->thumbUrl('md');
|
||||
|
||||
@@ -407,6 +407,7 @@ final class CreatorStudioContentService
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
|
||||
$status = (string) ($item['status'] ?? '');
|
||||
$isDraft = ($item['status'] ?? null) === 'draft';
|
||||
$missing = [];
|
||||
$score = 0;
|
||||
@@ -441,6 +442,16 @@ final class CreatorStudioContentService
|
||||
default => 'Needs more work',
|
||||
};
|
||||
|
||||
$readiness = $status === 'published'
|
||||
? null
|
||||
: [
|
||||
'score' => $score,
|
||||
'max' => 4,
|
||||
'label' => $label,
|
||||
'can_publish' => $score >= 3,
|
||||
'missing' => $missing,
|
||||
];
|
||||
|
||||
$workflowActions = match ((string) ($item['module'] ?? '')) {
|
||||
'artworks' => [
|
||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
||||
@@ -466,13 +477,7 @@ final class CreatorStudioContentService
|
||||
'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)),
|
||||
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
|
||||
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
|
||||
'readiness' => [
|
||||
'score' => $score,
|
||||
'max' => 4,
|
||||
'label' => $label,
|
||||
'can_publish' => $score >= 3,
|
||||
'missing' => $missing,
|
||||
],
|
||||
'readiness' => $readiness,
|
||||
'cross_module_actions' => $workflowActions,
|
||||
];
|
||||
|
||||
|
||||
@@ -50,9 +50,10 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('is_public', false)
|
||||
->orWhere('artwork_status', 'draft');
|
||||
$query->whereNull('artwork_status')
|
||||
->orWhere('artwork_status', '!=', 'scheduled');
|
||||
})
|
||||
->where('is_public', false)
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
@@ -92,16 +93,29 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->with([
|
||||
'stats',
|
||||
'categories',
|
||||
'tags',
|
||||
'features' => function ($query): void {
|
||||
$query->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
},
|
||||
])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('is_public', false)
|
||||
->orWhere('artwork_status', 'draft');
|
||||
});
|
||||
$builder->whereNull('artwork_status')
|
||||
->orWhere('artwork_status', '!=', 'scheduled');
|
||||
})
|
||||
->where('is_public', false);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('artwork_status', 'scheduled');
|
||||
@@ -199,7 +213,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'schedule_timezone' => $artwork->artwork_timezone,
|
||||
'featured' => false,
|
||||
'featured' => $artwork->features->isNotEmpty(),
|
||||
'metrics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'appreciation' => (int) ($stats?->favorites ?? 0),
|
||||
|
||||
@@ -31,6 +31,8 @@ final class StudioBulkActionService
|
||||
$query = Artwork::where('user_id', $userId);
|
||||
if ($action === 'unarchive') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($action === 'delete') {
|
||||
$query->withTrashed();
|
||||
}
|
||||
$artworks = $query->whereIn('id', $artworkIds)->get();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user