Files
SkinbaseNova/app/Http/Controllers/Api/SuggestedCreatorsController.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

218 lines
9.4 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\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendation\UserPreferenceBuilder;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* GET /api/user/suggestions/creators
*
* Returns up to 12 creators the authenticated user might want to follow.
*
* Ranking algorithm (Phase 1 no embeddings):
* 1. Creators followed by people you follow (mutual-follow signal)
* 2. Creators whose recent works overlap your top tags
* 3. High-quality creators (followers_count / artworks_count) in your categories
*
* Exclusions: yourself, already-followed creators.
*
* Cached per user for config('recommendations.ttl.creator_suggestions') seconds (default 30 min).
*/
final class SuggestedCreatorsController extends Controller
{
private const LIMIT = 12;
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$ttl = (int) config('recommendations.ttl.creator_suggestions', 30 * 60);
$cacheKey = "creator_suggestions:{$user->id}";
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
return $this->buildSuggestions($user);
});
return response()->json(['data' => $data]);
}
private function buildSuggestions(\App\Models\User $user): array
{
try {
$profile = $this->prefBuilder->build($user);
$followingIds = $profile->strongCreatorIds;
$topTagSlugs = array_slice($profile->topTagSlugs, 0, 10);
// ── 1. Mutual-follow candidates ───────────────────────────────────
$mutualCandidates = [];
if ($followingIds !== []) {
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->whereIn('uf.follower_id', $followingIds)
->where('uf.user_id', '!=', $user->id)
->whereNotIn('uf.user_id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->selectRaw('
u.id,
u.name,
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count,
COUNT(*) as mutual_weight
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
->orderByDesc('mutual_weight')
->limit(20)
->get();
foreach ($rows as $row) {
$mutualCandidates[(int) $row->id] = [
'id' => (int) $row->id,
'name' => $row->name,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'artworks_count' => (int) $row->artworks_count,
'score' => (float) $row->mutual_weight * 3.0,
'reason' => 'Popular among creators you follow',
];
}
}
// ── 2. Tag-affinity candidates ────────────────────────────────────
$tagCandidates = [];
if ($topTagSlugs !== []) {
$tagFilter = implode(',', array_fill(0, count($topTagSlugs), '?'));
$rows = DB::table('tags as t')
->join('artwork_tag as at', 'at.tag_id', '=', 't.id')
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
->join('users as u', 'u.id', '=', 'a.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->whereIn('t.slug', $topTagSlugs)
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('a.deleted_at')
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->selectRaw('
u.id,
u.name,
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count,
COUNT(DISTINCT t.id) as matched_tags
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
->orderByDesc('matched_tags')
->limit(20)
->get();
foreach ($rows as $row) {
if (isset($mutualCandidates[(int) $row->id])) {
// Boost mutual candidate that also matches tags
$mutualCandidates[(int) $row->id]['score'] += (float) $row->matched_tags;
continue;
}
$tagCandidates[(int) $row->id] = [
'id' => (int) $row->id,
'name' => $row->name,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'artworks_count' => (int) $row->artworks_count,
'score' => (float) $row->matched_tags * 2.0,
'reason' => 'Matches your interests',
];
}
}
// ── 3. Merge & rank ───────────────────────────────────────────────
$combined = array_values(array_merge($mutualCandidates, $tagCandidates));
usort($combined, fn ($a, $b) => $b['score'] <=> $a['score']);
$top = array_slice($combined, 0, self::LIMIT);
if (count($top) < self::LIMIT) {
$topIds = array_column($top, 'id');
$excluded = array_unique(array_merge($followingIds, [$user->id], $topIds));
$top = array_merge($top, $this->highQualityFallback($excluded, self::LIMIT - count($top)));
}
return array_map(fn (array $c): array => [
'id' => $c['id'],
'name' => $c['name'],
'username' => $c['username'],
'url' => $c['username'] ? '/@' . $c['username'] : '/profile/' . $c['id'],
'avatar' => AvatarUrl::forUser((int) $c['id'], $c['avatar_hash'] ?? null, 64),
'followers_count' => (int) ($c['followers_count'] ?? 0),
'artworks_count' => (int) ($c['artworks_count'] ?? 0),
'reason' => $c['reason'] ?? null,
], $top);
} catch (\Throwable $e) {
Log::warning('SuggestedCreatorsController: failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* @param array<int, int> $excludedIds
* @return array<int, array<string, mixed>>
*/
private function highQualityFallback(array $excludedIds, int $limit): array
{
if ($limit <= 0) {
return [];
}
$rows = 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')
->whereNotIn('u.id', $excludedIds)
->where('u.is_active', true)
->selectRaw('
u.id,
u.name,
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count
')
->orderByDesc('followers_count')
->limit($limit)
->get();
return $rows->map(fn ($r) => [
'id' => (int) $r->id,
'name' => $r->name,
'username' => $r->username,
'avatar_hash' => $r->avatar_hash,
'followers_count' => (int) $r->followers_count,
'artworks_count' => (int) $r->artworks_count,
'score' => (float) $r->followers_count * 0.1,
'reason' => 'Popular creator',
])->all();
}
}