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:
@@ -24,7 +24,8 @@ use Illuminate\Support\Facades\Cache;
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
/** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */
|
||||
private const CACHE_TTL = 1800; // 30 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
|
||||
@@ -50,9 +51,9 @@ final class SimilarArtworksController extends Controller
|
||||
|
||||
private function findSimilar(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$orientation = $this->orientation($artwork);
|
||||
$srcOrientation = $this->orientation($artwork);
|
||||
|
||||
// Build Meilisearch filter: exclude self and same creator
|
||||
$filterParts = [
|
||||
@@ -62,11 +63,6 @@ final class SimilarArtworksController extends Controller
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
// Filter by same orientation (landscape/portrait) — improves visual coherence
|
||||
if ($orientation !== 'square') {
|
||||
$filterParts[] = 'orientation = "' . $orientation . '"';
|
||||
}
|
||||
|
||||
// Priority 1: tag overlap (OR match across tags)
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
@@ -83,27 +79,80 @@ final class SimilarArtworksController extends Controller
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate(self::LIMIT);
|
||||
->paginate(200, 'page', 1);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn (Artwork $a): array => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'slug' => $a->slug,
|
||||
'thumb' => $a->thumbUrl('md'),
|
||||
'url' => '/art/' . $a->id . '/' . $a->slug,
|
||||
'author_id' => $a->user_id,
|
||||
'orientation' => $this->orientation($a),
|
||||
'width' => $a->width,
|
||||
'height' => $a->height,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
$collection = $results->getCollection();
|
||||
$collection->load(['tags:id,slug', 'stats']);
|
||||
|
||||
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
|
||||
$srcTagSet = array_flip($tagSlugs);
|
||||
$srcW = (int) ($artwork->width ?? 0);
|
||||
$srcH = (int) ($artwork->height ?? 0);
|
||||
|
||||
$scored = $collection->map(function (Artwork $candidate) use (
|
||||
$srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH
|
||||
): array {
|
||||
$cTagSlugs = $candidate->tags->pluck('slug')->all();
|
||||
$cTagSet = array_flip($cTagSlugs);
|
||||
|
||||
// Tag overlap (Sørensen–Dice-like)
|
||||
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
||||
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
||||
$tagOverlap = $common / $total;
|
||||
|
||||
// Orientation bonus
|
||||
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
||||
|
||||
// Resolution proximity bonus (both axes within 25 %)
|
||||
$cW = (int) ($candidate->width ?? 0);
|
||||
$cH = (int) ($candidate->height ?? 0);
|
||||
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
|
||||
&& abs($cW - $srcW) / $srcW <= 0.25
|
||||
&& abs($cH - $srcH) / $srcH <= 0.25
|
||||
) ? 0.05 : 0.0;
|
||||
|
||||
// Popularity boost (log-normalised views, capped at 0.15)
|
||||
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
||||
$popularity = min(0.15, log(1 + $views) / 13.0);
|
||||
|
||||
// Freshness boost (exp decay, 60-day half-life, weight 0.10)
|
||||
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$freshness = exp(-$ageDays / 60.0) * 0.10;
|
||||
|
||||
$score = $tagOverlap * 0.60
|
||||
+ $orientBonus
|
||||
+ $resBonus
|
||||
+ $popularity
|
||||
+ $freshness;
|
||||
|
||||
return ['score' => $score, 'artwork' => $candidate];
|
||||
})->all();
|
||||
|
||||
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_values(
|
||||
array_map(fn (array $item): array => [
|
||||
'id' => $item['artwork']->id,
|
||||
'title' => $item['artwork']->title,
|
||||
'slug' => $item['artwork']->slug,
|
||||
'thumb' => $item['artwork']->thumbUrl('md'),
|
||||
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
|
||||
'author_id' => $item['artwork']->user_id,
|
||||
'orientation' => $this->orientation($item['artwork']),
|
||||
'width' => $item['artwork']->width,
|
||||
'height' => $item['artwork']->height,
|
||||
'score' => round((float) $item['score'], 5),
|
||||
], array_slice($scored, 0, self::LIMIT))
|
||||
);
|
||||
}
|
||||
|
||||
private function orientation(Artwork $artwork): string
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
152
app/Http/Controllers/Api/SuggestedTagsController.php
Normal file
152
app/Http/Controllers/Api/SuggestedTagsController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
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/tags
|
||||
*
|
||||
* Returns up to 20 tag suggestions for the authenticated user.
|
||||
*
|
||||
* Sources:
|
||||
* 1. Tags from the user's favourited artworks and awards (affinity-ranked)
|
||||
* 2. Trending tags from global activity (fallback / discovery)
|
||||
*
|
||||
* Does NOT require the user to follow tags. This endpoint provides the foundation
|
||||
* for a future "follow tags" feature while being useful immediately as discovery input.
|
||||
*
|
||||
* Cached per user for config('recommendations.ttl.tag_suggestions') seconds (default 60 min).
|
||||
*/
|
||||
final class SuggestedTagsController extends Controller
|
||||
{
|
||||
private const LIMIT = 20;
|
||||
|
||||
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$ttl = (int) config('recommendations.ttl.tag_suggestions', 60 * 60);
|
||||
$cacheKey = "tag_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);
|
||||
$knownTagSlugs = $profile->topTagSlugs; // already in user's profile – skip
|
||||
|
||||
// ── Personalised tags (with normalised weights) ───────────────────
|
||||
$personalised = [];
|
||||
foreach ($profile->tagWeights as $slug => $weight) {
|
||||
if ($weight > 0.0) {
|
||||
$personalised[$slug] = (float) $weight;
|
||||
}
|
||||
}
|
||||
arsort($personalised);
|
||||
|
||||
// ── Trending tags (global, last 7 days) ───────────────────────────
|
||||
$trending = $this->trendingTags(40);
|
||||
|
||||
// ── Merge: personalised first, then trending discovery ─────────────
|
||||
$merged = [];
|
||||
foreach ($personalised as $slug => $weight) {
|
||||
$merged[$slug] = [
|
||||
'slug' => $slug,
|
||||
'score' => $weight * 2.0, // boost personal signal
|
||||
'source' => 'affinity',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($trending as $row) {
|
||||
$slug = (string) $row->slug;
|
||||
if (isset($merged[$slug])) {
|
||||
$merged[$slug]['score'] += (float) $row->trend_score;
|
||||
} else {
|
||||
$merged[$slug] = [
|
||||
'slug' => $slug,
|
||||
'score' => (float) $row->trend_score,
|
||||
'source' => 'trending',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($merged, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
$top = array_slice(array_values($merged), 0, self::LIMIT);
|
||||
|
||||
// ── Hydrate with DB info ──────────────────────────────────────────
|
||||
$slugs = array_column($top, 'slug');
|
||||
$tagRows = DB::table('tags')
|
||||
->whereIn('slug', $slugs)
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'slug', 'usage_count'])
|
||||
->keyBy('slug');
|
||||
|
||||
$result = [];
|
||||
foreach ($top as $item) {
|
||||
$tag = $tagRows->get($item['slug']);
|
||||
if ($tag === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
'usage_count' => (int) $tag->usage_count,
|
||||
'source' => (string) $item['source'],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SuggestedTagsController: failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate tag usage over the last 7 days as a proxy for trend score.
|
||||
* Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
|
||||
*/
|
||||
private function trendingTags(int $limit): \Illuminate\Support\Collection
|
||||
{
|
||||
$since = now()->subDays(7);
|
||||
|
||||
return DB::table('artwork_tag as at')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->where('a.published_at', '>=', $since)
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('t.is_active', true)
|
||||
->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score')
|
||||
->groupBy('t.id', 't.slug')
|
||||
->orderByDesc('trend_score')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user