- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
173 lines
6.7 KiB
PHP
173 lines
6.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Artwork;
|
||
use App\Services\ArtworkSearchService;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Support\Facades\Cache;
|
||
|
||
/**
|
||
* GET /api/art/{id}/similar
|
||
*
|
||
* Returns up to 12 similar artworks based on:
|
||
* 1. Tag overlap (primary signal)
|
||
* 2. Same category
|
||
* 3. Similar orientation
|
||
*
|
||
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
|
||
* Current artwork and its creator are excluded from results.
|
||
*/
|
||
final class SimilarArtworksController extends Controller
|
||
{
|
||
private const LIMIT = 12;
|
||
/** 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) {}
|
||
|
||
public function __invoke(int $id): JsonResponse
|
||
{
|
||
$artwork = Artwork::public()
|
||
->published()
|
||
->with(['tags:id,slug', 'categories:id,slug'])
|
||
->find($id);
|
||
|
||
if (! $artwork) {
|
||
return response()->json(['error' => 'Artwork not found'], 404);
|
||
}
|
||
|
||
$cacheKey = "api.similar.{$artwork->id}";
|
||
|
||
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
|
||
return $this->findSimilar($artwork);
|
||
});
|
||
|
||
return response()->json(['data' => $items]);
|
||
}
|
||
|
||
private function findSimilar(Artwork $artwork): array
|
||
{
|
||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||
$srcOrientation = $this->orientation($artwork);
|
||
|
||
// Build Meilisearch filter: exclude self and same creator
|
||
$filterParts = [
|
||
'is_public = true',
|
||
'is_approved = true',
|
||
'id != ' . $artwork->id,
|
||
'author_id != ' . $artwork->user_id,
|
||
];
|
||
|
||
// Priority 1: tag overlap (OR match across tags)
|
||
if ($tagSlugs !== []) {
|
||
$tagFilter = implode(' OR ', array_map(
|
||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||
$tagSlugs
|
||
));
|
||
$filterParts[] = '(' . $tagFilter . ')';
|
||
} elseif ($categorySlugs !== []) {
|
||
// Fallback to category if no tags
|
||
$catFilter = implode(' OR ', array_map(
|
||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||
$categorySlugs
|
||
));
|
||
$filterParts[] = '(' . $catFilter . ')';
|
||
}
|
||
|
||
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||
$results = Artwork::search('')
|
||
->options([
|
||
'filter' => implode(' AND ', $filterParts),
|
||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||
])
|
||
->paginate(200, 'page', 1);
|
||
|
||
$collection = $results->getCollection();
|
||
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
|
||
|
||
// ── 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' => $item['artwork']->user?->name ?? 'Artist',
|
||
'author_avatar' => $item['artwork']->user?->profile?->avatar_url,
|
||
'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
|
||
{
|
||
if (! $artwork->width || ! $artwork->height) {
|
||
return 'square';
|
||
}
|
||
|
||
return match (true) {
|
||
$artwork->width > $artwork->height => 'landscape',
|
||
$artwork->height > $artwork->width => 'portrait',
|
||
default => 'square',
|
||
};
|
||
}
|
||
}
|