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
This commit is contained in:
217
app/Http/Controllers/Api/SuggestedCreatorsController.php
Normal file
217
app/Http/Controllers/Api/SuggestedCreatorsController.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user