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:
2026-02-27 13:34:08 +01:00
parent 09eadf9003
commit 67ef79766c
37 changed files with 3096 additions and 58 deletions

View File

@@ -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 3060 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ørensenDice-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