Files
SkinbaseNova/app/Services/HomepageService.php
Gregor Klevze 67ef79766c fix(gallery): fill tall portrait cards to full block width with object-cover crop
- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so
  object-cover fills the max-height capped box instead of collapsing the width
- MasonryGallery.css: add width:100% to media container, position img
  absolutely so top/bottom is cropped rather than leaving dark gaps
- Add React MasonryGallery + ArtworkCard components and entry point
- Add recommendation system: UserRecoProfile model/DTO/migration,
  SuggestedCreatorsController, SuggestedTagsController, Recommendation
  services, config/recommendations.php
- SimilarArtworksController, DiscoverController, HomepageService updates
- Update routes (api + web) and discover/for-you views
- Refresh favicon assets, update vite.config.js
2026-02-27 13:34:08 +01:00

540 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\Recommendation\RecommendationService;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\QueryException;
/**
* HomepageService
*
* Aggregates all data sections needed for the Nova homepage.
* All results are cached for CACHE_TTL seconds.
* Controllers stay thin — only call the aggregator.
*/
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
private readonly RecommendationService $reco,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Public aggregator
// ─────────────────────────────────────────────────────────────────────────
/**
* Return all homepage section data as a single array ready to JSON-encode.
*/
public function all(): array
{
return [
'hero' => $this->getHeroArtwork(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
];
}
/**
* Personalized homepage data for an authenticated user.
*
* Sections:
* 1. user_data welcome row counts (messages, notifications, new followers)
* 2. from_following artworks from creators you follow
* 3. trending same trending feed as guests
* 4. by_tags artworks matching user's top tags (Trending For You)
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
$prefs = $this->prefs->build($user);
return [
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
'preferences' => [
'top_tags' => $prefs['top_tags'] ?? [],
'top_categories' => $prefs['top_categories'] ?? [],
],
];
}
/**
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
*
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
* diversity controls, and its own Redis cache layer.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
return $this->reco->forYouPreview($user, $limit);
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
/**
* Hero artwork: first item from the featured list.
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
$result = $this->artworks->getFeaturedArtworks(null, 1);
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$artwork = $result->getCollection()->first();
} elseif ($result instanceof \Illuminate\Support\Collection) {
$artwork = $result->first();
} elseif (is_array($result)) {
$artwork = $result[0] ?? null;
} else {
$artwork = null;
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
});
}
/**
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
*
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
* Spec: no heavy joins in the hot path.
*/
public function getTrending(int $limit = 10): array
{
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true',
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
if ($results->isEmpty()) {
return $this->getTrendingFromDb($limit);
}
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getTrendingFromDb($limit);
}
});
}
/**
* DB-only fallback for trending (Meilisearch unavailable).
* Uses pre-computed trending_score_7d column — no correlated subqueries.
*/
private function getTrendingFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->orderByDesc('trending_score_7d')
->orderByDesc('trending_score_24h')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Fresh uploads: latest 12 approved public artworks.
*/
public function getFreshUploads(int $limit = 10): array
{
return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->orderByDesc('published_at')
->limit($limit)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
});
}
/**
* Top 12 popular tags by usage_count.
*/
public function getPopularTags(int $limit = 12): array
{
return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
return Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count'])
->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'slug' => $t->slug,
'count' => (int) $t->usage_count,
])
->values()
->all();
});
}
/**
* Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
* "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
*/
public function getCreatorSpotlight(int $limit = 6): array
{
return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$since = now()->subWeek();
$rows = DB::table('artworks')
->join('users as u', 'u.id', '=', 'artworks.user_id')
->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')
->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')
)
->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')
->orderByDesc('weekly_uploads')
->orderByDesc('total_awards')
->orderByDesc('total_views')
->limit($limit)
->get();
$userIds = $rows->pluck('id')->all();
// Pick one random artwork thumbnail per creator for the card background.
$thumbsByUser = Artwork::public()
->published()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->inRandomOrder()
->get(['id', 'user_id', 'hash', 'thumb_ext'])
->groupBy('user_id');
return $rows->map(function ($u) use ($thumbsByUser) {
$artworkForBg = $thumbsByUser->get($u->id)?->first();
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
return [
'id' => $u->id,
'name' => $u->name,
'uploads' => (int) $u->upload_count,
'weekly_uploads' => (int) $u->weekly_uploads,
'views' => (int) $u->total_views,
'awards' => (int) $u->total_awards,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
'bg_thumb' => $bgThumb,
];
})->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getCreatorSpotlight DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
/**
* Latest 5 news posts from the forum news category.
*/
public function getNews(int $limit = 5): array
{
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$items = DB::table('forum_threads as t')
->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
->where(function ($q) {
$q->where('t.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->whereNull('t.deleted_at')
->orderByDesc('t.created_at')
->limit($limit)
->get();
return $items->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'date' => $row->created_at,
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
])->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getNews DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
// ─────────────────────────────────────────────────────────────────────────
// Personalized sections (auth only)
// ─────────────────────────────────────────────────────────────────────────
/**
* Welcome-row counts: unread messages, unread notifications, new followers.
* Returns quickly from DB using simple COUNTs; never throws.
*/
public function getUserData(\App\Models\User $user): array
{
try {
$unreadMessages = DB::table('conversations as c')
->join('conversation_participants as cp', 'cp.conversation_id', '=', 'c.id')
->join('messages as m', 'm.conversation_id', '=', 'c.id')
->where('cp.user_id', $user->id)
->where('m.user_id', '!=', $user->id)
->whereColumn('m.created_at', '>', 'cp.last_read_at')
->distinct('c.id')
->count('c.id');
} catch (\Throwable) {
$unreadMessages = 0;
}
try {
$unreadNotifications = DB::table('notifications')
->where('user_id', $user->id)
->whereNull('read_at')
->count();
} catch (\Throwable) {
$unreadNotifications = 0;
}
return [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 64),
'messages_unread' => (int) $unreadMessages,
'notifications_unread' => (int) $unreadNotifications,
'followers_count' => (int) ($user->statistics?->followers_count ?? 0),
];
}
/**
* Suggested creators: active public uploaders NOT already followed by the user,
* ranked by follower count. Optionally filtered to the user's top categories.
*/
public function getSuggestedCreators(\App\Models\User $user, array $prefs, int $limit = 8): array
{
return Cache::remember(
"homepage.suggested.{$user->id}",
300,
function () use ($user, $prefs, $limit): array {
try {
$followingIds = $prefs['followed_creators'] ?? [];
$query = DB::table('users as u')
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.artworks_count, 0) as artworks_count'),
)
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->orderByDesc('followers_count')
->orderByDesc('artworks_count')
->limit($limit);
$rows = $query->get();
return $rows->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 64),
'followers_count' => (int) $u->followers_count,
'artworks_count' => (int) $u->artworks_count,
])->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
return [];
}
}
);
}
/**
* Latest artworks from creators the user follows (max 12).
*/
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
{
$followingIds = $prefs['followed_creators'] ?? [];
if (empty($followingIds)) {
return [];
}
return Cache::remember(
"homepage.following.{$user->id}",
60, // short TTL personal data
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(10)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
}
);
}
/**
* Artworks matching the user's top tags (max 12).
* Powered by Meilisearch.
*/
public function getByTags(array $tagSlugs): array
{
if (empty($tagSlugs)) {
return [];
}
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Fresh artworks in the user's favourite categories (max 12).
* Powered by Meilisearch.
*/
public function getByCategories(array $categorySlugs): array
{
if (empty($categorySlugs)) {
return [];
}
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$thumbMd = $artwork->thumbUrl('md');
$thumbLg = $artwork->thumbUrl('lg');
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
$authorId = $artwork->user_id;
$authorName = $artwork->user?->name ?? 'Artist';
$authorUsername = $artwork->user?->username ?? '';
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40);
return [
'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug,
'author' => $authorName,
'author_id' => $authorId,
'author_username' => $authorUsername,
'author_avatar' => $authorAvatar,
'thumb' => $thumb,
'thumb_md' => $thumbMd,
'thumb_lg' => $thumbLg,
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
];
}
}