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
94
app/DTOs/UserRecoProfileDTO.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight value object representing a user's recommendation preference profile.
|
||||||
|
*
|
||||||
|
* Built by UserPreferenceBuilder from signals:
|
||||||
|
* - favourited artworks (+3)
|
||||||
|
* - awards given (+5)
|
||||||
|
* - creator follows (+2 for their tags)
|
||||||
|
* - own uploads (category bias)
|
||||||
|
*
|
||||||
|
* Cached in `user_reco_profiles` with a configurable TTL (default 6 hours).
|
||||||
|
*/
|
||||||
|
final class UserRecoProfileDTO
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $topTagSlugs Top tag slugs by weighted score (up to 20)
|
||||||
|
* @param array<int, string> $topCategorySlugs Top category slugs (up to 5)
|
||||||
|
* @param array<int, int> $strongCreatorIds Followed creator user IDs (up to 50)
|
||||||
|
* @param array<string, float> $tagWeights Tag slug → normalised weight (0–1)
|
||||||
|
* @param array<string, float> $categoryWeights Category slug → normalised weight
|
||||||
|
* @param array<int, string> $dislikedTagSlugs Future: blocked/hidden tag slugs
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $topTagSlugs = [],
|
||||||
|
public readonly array $topCategorySlugs = [],
|
||||||
|
public readonly array $strongCreatorIds = [],
|
||||||
|
public readonly array $tagWeights = [],
|
||||||
|
public readonly array $categoryWeights = [],
|
||||||
|
public readonly array $dislikedTagSlugs = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the user has enough signals to drive personalised recommendations.
|
||||||
|
*/
|
||||||
|
public function hasSignals(): bool
|
||||||
|
{
|
||||||
|
return $this->topTagSlugs !== [] || $this->strongCreatorIds !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the normalised tag weight for a given slug (0.0 if unknown).
|
||||||
|
*/
|
||||||
|
public function tagWeight(string $slug): float
|
||||||
|
{
|
||||||
|
return (float) ($this->tagWeights[$slug] ?? 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the creator is in the user's strong-follow list.
|
||||||
|
*/
|
||||||
|
public function followsCreator(int $userId): bool
|
||||||
|
{
|
||||||
|
return in_array($userId, $this->strongCreatorIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise for storage in the DB / Redis cache.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'top_tags' => $this->topTagSlugs,
|
||||||
|
'top_categories' => $this->topCategorySlugs,
|
||||||
|
'strong_creators' => $this->strongCreatorIds,
|
||||||
|
'tag_weights' => $this->tagWeights,
|
||||||
|
'category_weights' => $this->categoryWeights,
|
||||||
|
'disliked_tags' => $this->dislikedTagSlugs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-hydrate from a stored array (e.g. from the DB JSON column).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
topTagSlugs: (array) ($data['top_tags'] ?? []),
|
||||||
|
topCategorySlugs: (array) ($data['top_categories'] ?? []),
|
||||||
|
strongCreatorIds: array_map('intval', (array) ($data['strong_creators'] ?? [])),
|
||||||
|
tagWeights: array_map('floatval', (array) ($data['tag_weights'] ?? [])),
|
||||||
|
categoryWeights: array_map('floatval', (array) ($data['category_weights'] ?? [])),
|
||||||
|
dislikedTagSlugs: (array) ($data['disliked_tags'] ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
final class SimilarArtworksController extends Controller
|
final class SimilarArtworksController extends Controller
|
||||||
{
|
{
|
||||||
private const LIMIT = 12;
|
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) {}
|
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||||
|
|
||||||
@@ -50,9 +51,9 @@ final class SimilarArtworksController extends Controller
|
|||||||
|
|
||||||
private function findSimilar(Artwork $artwork): array
|
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();
|
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||||
$orientation = $this->orientation($artwork);
|
$srcOrientation = $this->orientation($artwork);
|
||||||
|
|
||||||
// Build Meilisearch filter: exclude self and same creator
|
// Build Meilisearch filter: exclude self and same creator
|
||||||
$filterParts = [
|
$filterParts = [
|
||||||
@@ -62,11 +63,6 @@ final class SimilarArtworksController extends Controller
|
|||||||
'author_id != ' . $artwork->user_id,
|
'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)
|
// Priority 1: tag overlap (OR match across tags)
|
||||||
if ($tagSlugs !== []) {
|
if ($tagSlugs !== []) {
|
||||||
$tagFilter = implode(' OR ', array_map(
|
$tagFilter = implode(' OR ', array_map(
|
||||||
@@ -83,27 +79,80 @@ final class SimilarArtworksController extends Controller
|
|||||||
$filterParts[] = '(' . $catFilter . ')';
|
$filterParts[] = '(' . $catFilter . ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||||||
$results = Artwork::search('')
|
$results = Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => implode(' AND ', $filterParts),
|
'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()
|
$collection = $results->getCollection();
|
||||||
->map(fn (Artwork $a): array => [
|
$collection->load(['tags:id,slug', 'stats']);
|
||||||
'id' => $a->id,
|
|
||||||
'title' => $a->title,
|
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||||
'slug' => $a->slug,
|
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||||
'thumb' => $a->thumbUrl('md'),
|
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
|
||||||
'url' => '/art/' . $a->id . '/' . $a->slug,
|
$srcTagSet = array_flip($tagSlugs);
|
||||||
'author_id' => $a->user_id,
|
$srcW = (int) ($artwork->width ?? 0);
|
||||||
'orientation' => $this->orientation($a),
|
$srcH = (int) ($artwork->height ?? 0);
|
||||||
'width' => $a->width,
|
|
||||||
'height' => $a->height,
|
$scored = $collection->map(function (Artwork $candidate) use (
|
||||||
])
|
$srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH
|
||||||
->values()
|
): array {
|
||||||
->all();
|
$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
|
private function orientation(Artwork $artwork): string
|
||||||
|
|||||||
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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\Recommendation\RecommendationService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -21,12 +22,14 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
* - /discover/top-rated → highest favourite count
|
* - /discover/top-rated → highest favourite count
|
||||||
* - /discover/most-downloaded → most downloaded all-time
|
* - /discover/most-downloaded → most downloaded all-time
|
||||||
* - /discover/on-this-day → published on this calendar day in previous years
|
* - /discover/on-this-day → published on this calendar day in previous years
|
||||||
|
* - /discover/for-you → personalised feed (auth required)
|
||||||
*/
|
*/
|
||||||
final class DiscoverController extends Controller
|
final class DiscoverController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkService $artworkService,
|
private readonly ArtworkService $artworkService,
|
||||||
private readonly ArtworkSearchService $searchService,
|
private readonly ArtworkSearchService $searchService,
|
||||||
|
private readonly RecommendationService $recoService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── /discover/trending ──────────────────────────────────────────────────
|
// ─── /discover/trending ──────────────────────────────────────────────────
|
||||||
@@ -178,6 +181,56 @@ final class DiscoverController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── /discover/for-you ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personalised "For You" feed page.
|
||||||
|
*
|
||||||
|
* Uses RecommendationService (Phase 1 tag-affinity + creator-affinity pipeline)
|
||||||
|
* and renders the standard discover grid view. Guest users are redirected
|
||||||
|
* to the trending page per spec.
|
||||||
|
*/
|
||||||
|
public function forYou(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$limit = 40;
|
||||||
|
$cursor = $request->query('cursor') ?: null;
|
||||||
|
|
||||||
|
// Retrieve the paginated feed (service handles Meilisearch + reranking + cache)
|
||||||
|
$feedResult = $this->recoService->forYouFeed(
|
||||||
|
user: $user,
|
||||||
|
limit: $limit,
|
||||||
|
cursor: is_string($cursor) ? $cursor : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$artworkItems = $feedResult['data'] ?? [];
|
||||||
|
|
||||||
|
// Build a simple presentable collection
|
||||||
|
$artworks = collect($artworkItems)->map(fn (array $item) => (object) [
|
||||||
|
'id' => $item['id'] ?? 0,
|
||||||
|
'name' => $item['title'] ?? 'Untitled',
|
||||||
|
'category_name' => '',
|
||||||
|
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||||
|
'thumb_srcset' => $item['thumbnail_url'] ?? null,
|
||||||
|
'uname' => $item['author'] ?? 'Artist',
|
||||||
|
'published_at' => null,
|
||||||
|
'slug' => $item['slug'] ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$meta = $feedResult['meta'] ?? [];
|
||||||
|
$nextCursor = $meta['next_cursor'] ?? null;
|
||||||
|
|
||||||
|
return view('web.discover.for-you', [
|
||||||
|
'artworks' => $artworks,
|
||||||
|
'page_title' => 'For You',
|
||||||
|
'section' => 'for-you',
|
||||||
|
'description' => 'Artworks picked for you based on your taste.',
|
||||||
|
'icon' => 'fa-wand-magic-sparkles',
|
||||||
|
'next_cursor' => $nextCursor,
|
||||||
|
'cache_status' => $meta['cache_status'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── /discover/following ─────────────────────────────────────────────────
|
// ─── /discover/following ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function following(Request $request)
|
public function following(Request $request)
|
||||||
@@ -264,11 +317,14 @@ final class DiscoverController extends Controller
|
|||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
'name' => $artwork->title,
|
'name' => $artwork->title,
|
||||||
'category_name' => $primaryCategory->name ?? '',
|
'category_name' => $primaryCategory->name ?? '',
|
||||||
|
'category_slug' => $primaryCategory->slug ?? '',
|
||||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||||
'thumb_url' => $present['url'],
|
'thumb_url' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||||
'published_at' => $artwork->published_at,
|
'published_at' => $artwork->published_at,
|
||||||
|
'width' => $artwork->width ?? null,
|
||||||
|
'height' => $artwork->height ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
app/Models/UserRecoProfile.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\DTOs\UserRecoProfileDTO;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted cache of a user's recommendation preference profile.
|
||||||
|
*
|
||||||
|
* Schema: user_reco_profiles (user_id PK, json columns, timestamps).
|
||||||
|
* Rebuilt by UserPreferenceBuilder → stale when updated_at < now() - TTL.
|
||||||
|
*/
|
||||||
|
class UserRecoProfile extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_reco_profiles';
|
||||||
|
|
||||||
|
protected $primaryKey = 'user_id';
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $keyType = 'int';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'top_tags_json' => 'array',
|
||||||
|
'top_categories_json' => 'array',
|
||||||
|
'followed_creator_ids_json' => 'array',
|
||||||
|
'tag_weights_json' => 'array',
|
||||||
|
'category_weights_json' => 'array',
|
||||||
|
'disliked_tag_ids_json' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Relations ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate a DTO from this model's JSON columns.
|
||||||
|
*/
|
||||||
|
public function toDTO(): UserRecoProfileDTO
|
||||||
|
{
|
||||||
|
return new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: (array) ($this->top_tags_json ?? []),
|
||||||
|
topCategorySlugs: (array) ($this->top_categories_json ?? []),
|
||||||
|
strongCreatorIds: array_map('intval', (array) ($this->followed_creator_ids_json ?? [])),
|
||||||
|
tagWeights: array_map('floatval', (array) ($this->tag_weights_json ?? [])),
|
||||||
|
categoryWeights: array_map('floatval', (array) ($this->category_weights_json ?? [])),
|
||||||
|
dislikedTagSlugs: (array) ($this->disliked_tag_ids_json ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the stored profile is still within the configured TTL.
|
||||||
|
*/
|
||||||
|
public function isFresh(): bool
|
||||||
|
{
|
||||||
|
if ($this->updated_at === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ttl = (int) config('recommendations.ttl.user_reco_profile', 6 * 3600);
|
||||||
|
|
||||||
|
return $this->updated_at->addSeconds($ttl)->isFuture();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
|
use App\Services\Recommendation\RecommendationService;
|
||||||
use App\Services\UserPreferenceService;
|
use App\Services\UserPreferenceService;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -26,9 +27,10 @@ final class HomepageService
|
|||||||
private const CACHE_TTL = 300; // 5 minutes
|
private const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkService $artworks,
|
private readonly ArtworkService $artworks,
|
||||||
private readonly ArtworkSearchService $search,
|
private readonly ArtworkSearchService $search,
|
||||||
private readonly UserPreferenceService $prefs,
|
private readonly UserPreferenceService $prefs,
|
||||||
|
private readonly RecommendationService $reco,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -70,6 +72,7 @@ final class HomepageService
|
|||||||
'is_logged_in' => true,
|
'is_logged_in' => true,
|
||||||
'user_data' => $this->getUserData($user),
|
'user_data' => $this->getUserData($user),
|
||||||
'hero' => $this->getHeroArtwork(),
|
'hero' => $this->getHeroArtwork(),
|
||||||
|
'for_you' => $this->getForYouPreview($user),
|
||||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||||
'trending' => $this->getTrending(),
|
'trending' => $this->getTrending(),
|
||||||
'fresh' => $this->getFreshUploads(),
|
'fresh' => $this->getFreshUploads(),
|
||||||
@@ -86,6 +89,22 @@ final class HomepageService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
|
||||||
|
*
|
||||||
|
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
|
||||||
|
* diversity controls, and its own Redis cache layer.
|
||||||
|
*/
|
||||||
|
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->reco->forYouPreview($user, $limit);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Sections
|
// Sections
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
420
app/Services/Recommendation/RecommendationService.php
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Recommendation;
|
||||||
|
|
||||||
|
use App\DTOs\UserRecoProfileDTO;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecommendationService – Phase 1 "For You" feed pipeline.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Load UserRecoProfileDTO (cached, built by UserPreferenceBuilder)
|
||||||
|
* 2. Generate 200 Meilisearch candidates filtered by user's top tags
|
||||||
|
* – Exclude user's own artworks
|
||||||
|
* – Exclude already-favourited artworks (PHP post-filter)
|
||||||
|
* 3. PHP reranking:
|
||||||
|
* score = (tag_overlap × w_tag_overlap)
|
||||||
|
* + (creator_affinity × w_creator_affinity)
|
||||||
|
* + (popularity_boost × w_popularity)
|
||||||
|
* + (freshness_boost × w_freshness)
|
||||||
|
* 4. Diversity controls:
|
||||||
|
* – max N results per creator per page (config recommendations.max_per_creator)
|
||||||
|
* – penalise repeated tag-pattern clusters
|
||||||
|
* 5. Cursor-based pagination, top 40 per page
|
||||||
|
* 6. Cold-start fallback (trending + fresh blend) when user has no signals
|
||||||
|
*
|
||||||
|
* Caching:
|
||||||
|
* – key: for_you:{user_id}:{cursor_hash} TTL: config recommendations.ttl.for_you_feed
|
||||||
|
*/
|
||||||
|
final class RecommendationService
|
||||||
|
{
|
||||||
|
private const DEFAULT_PAGE_SIZE = 40;
|
||||||
|
private const COLD_START_LIMIT = 40;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserPreferenceBuilder $prefBuilder
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a fully ranked, paginated "For You" feed for the given user.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* data: array<int, array<string, mixed>>,
|
||||||
|
* meta: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function forYouFeed(User $user, int $limit = self::DEFAULT_PAGE_SIZE, ?string $cursor = null): array
|
||||||
|
{
|
||||||
|
$safeLimit = max(1, min(50, $limit));
|
||||||
|
$cursorHash = $cursor ? md5($cursor) : '0';
|
||||||
|
$cacheKey = "for_you:{$user->id}:{$cursorHash}";
|
||||||
|
$ttl = (int) config('recommendations.ttl.for_you_feed', 5 * 60);
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, $ttl, function () use ($user, $safeLimit, $cursor) {
|
||||||
|
return $this->build($user, $safeLimit, $cursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for the homepage preview (first N items, no cursor).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function forYouPreview(User $user, int $limit = 12): array
|
||||||
|
{
|
||||||
|
$result = $this->forYouFeed($user, $limit);
|
||||||
|
return $result['data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Build pipeline ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private function build(User $user, int $limit, ?string $cursor): array
|
||||||
|
{
|
||||||
|
$profile = $this->prefBuilder->build($user);
|
||||||
|
|
||||||
|
$userId = (int) $user->id;
|
||||||
|
|
||||||
|
if (! $profile->hasSignals()) {
|
||||||
|
return $this->coldStart($userId, $limit, $cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
$poolSize = (int) config('recommendations.candidate_pool_size', 200);
|
||||||
|
$tagSlugs = array_slice($profile->topTagSlugs, 0, 10);
|
||||||
|
|
||||||
|
// ── 1. Meilisearch candidate retrieval ────────────────────────────────
|
||||||
|
$candidates = $this->fetchCandidates($tagSlugs, $userId, $poolSize);
|
||||||
|
|
||||||
|
if ($candidates->isEmpty()) {
|
||||||
|
return $this->coldStart($userId, $limit, $cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Exclude already-favourited artworks ────────────────────────────
|
||||||
|
$favoritedIds = $this->getFavoritedIds((int) $user->id);
|
||||||
|
$candidates = $candidates->whereNotIn('id', $favoritedIds)->values();
|
||||||
|
|
||||||
|
// ── 3. Enrich: load tags + stats for all candidates (2 IN queries) ────
|
||||||
|
$candidates->load(['tags:id,slug', 'stats']);
|
||||||
|
|
||||||
|
// ── 4. PHP reranking ──────────────────────────────────────────────────
|
||||||
|
$scored = $this->rerank($candidates, $profile);
|
||||||
|
|
||||||
|
// ── 5. Diversity controls ─────────────────────────────────────────────
|
||||||
|
$diversified = $this->applyDiversity($scored);
|
||||||
|
|
||||||
|
// ── 6. Paginate ───────────────────────────────────────────────────────
|
||||||
|
return $this->paginate($diversified, $limit, $cursor, $profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Meilisearch retrieval ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Artwork>
|
||||||
|
*/
|
||||||
|
private function fetchCandidates(array $tagSlugs, int $userId, int $poolSize): Collection
|
||||||
|
{
|
||||||
|
$filterParts = [
|
||||||
|
'is_public = true',
|
||||||
|
'is_approved = true',
|
||||||
|
'author_id != ' . $userId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($tagSlugs !== []) {
|
||||||
|
$tagFilter = implode(' OR ', array_map(
|
||||||
|
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||||
|
$tagSlugs
|
||||||
|
));
|
||||||
|
$filterParts[] = '(' . $tagFilter . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$results = Artwork::search('')
|
||||||
|
->options([
|
||||||
|
'filter' => implode(' AND ', $filterParts),
|
||||||
|
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||||
|
])
|
||||||
|
->paginate($poolSize, 'page', 1);
|
||||||
|
|
||||||
|
return $results->getCollection();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('RecommendationService: Meilisearch unavailable, using DB fallback', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->dbFallbackCandidates($userId, $tagSlugs, $poolSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB fallback when Meilisearch is unavailable.
|
||||||
|
*
|
||||||
|
* @return Collection<int, Artwork>
|
||||||
|
*/
|
||||||
|
private function dbFallbackCandidates(int $userId, array $tagSlugs, int $poolSize): Collection
|
||||||
|
{
|
||||||
|
$query = Artwork::public()
|
||||||
|
->published()
|
||||||
|
->where('user_id', '!=', $userId)
|
||||||
|
->orderByDesc('trending_score_7d')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->limit($poolSize);
|
||||||
|
|
||||||
|
if ($tagSlugs !== []) {
|
||||||
|
$query->whereHas('tags', fn ($q) => $q->whereIn('slug', $tagSlugs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reranking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score each candidate and return a sorted array of [score, artwork].
|
||||||
|
*
|
||||||
|
* @param Collection<int, Artwork> $candidates
|
||||||
|
* @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
|
||||||
|
*/
|
||||||
|
private function rerank(Collection $candidates, UserRecoProfileDTO $profile): array
|
||||||
|
{
|
||||||
|
$weights = (array) config('recommendations.weights', []);
|
||||||
|
$wTag = (float) ($weights['tag_overlap'] ?? 0.40);
|
||||||
|
$wCre = (float) ($weights['creator_affinity'] ?? 0.25);
|
||||||
|
$wPop = (float) ($weights['popularity'] ?? 0.20);
|
||||||
|
$wFresh = (float) ($weights['freshness'] ?? 0.15);
|
||||||
|
|
||||||
|
$userTagSet = array_flip($profile->topTagSlugs); // slug → index (fast lookup)
|
||||||
|
|
||||||
|
$scored = [];
|
||||||
|
|
||||||
|
foreach ($candidates as $artwork) {
|
||||||
|
$artworkTagSlugs = $artwork->tags->pluck('slug')->all();
|
||||||
|
$artworkTagSet = array_flip($artworkTagSlugs);
|
||||||
|
|
||||||
|
// ── Tag overlap (Jaccard-like) ─────────────────────────────────────
|
||||||
|
$commonTags = count(array_intersect_key($userTagSet, $artworkTagSet));
|
||||||
|
$totalTags = max(1, count($userTagSet) + count($artworkTagSet) - $commonTags);
|
||||||
|
$tagOverlap = $commonTags / $totalTags;
|
||||||
|
|
||||||
|
// ── Creator affinity ──────────────────────────────────────────────
|
||||||
|
$creatorAffinity = $profile->followsCreator((int) $artwork->user_id) ? 1.0 : 0.0;
|
||||||
|
|
||||||
|
// ── Popularity boost (log-normalised views) ───────────────────────
|
||||||
|
$views = max(0, (int) ($artwork->stats?->views ?? 0));
|
||||||
|
$popularity = min(1.0, log(1 + $views) / 12.0);
|
||||||
|
|
||||||
|
// ── Freshness boost (exponential decay over 30 days) ─────────────
|
||||||
|
$publishedAt = $artwork->published_at ?? $artwork->created_at ?? now();
|
||||||
|
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||||
|
$freshness = exp(-$ageDays / 30.0);
|
||||||
|
|
||||||
|
$score = ($wTag * $tagOverlap)
|
||||||
|
+ ($wCre * $creatorAffinity)
|
||||||
|
+ ($wPop * $popularity)
|
||||||
|
+ ($wFresh * $freshness);
|
||||||
|
|
||||||
|
$scored[] = [
|
||||||
|
'score' => $score,
|
||||||
|
'artwork' => $artwork,
|
||||||
|
'tag_slugs' => $artworkTagSlugs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||||
|
|
||||||
|
return $scored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Diversity controls ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply per-creator cap and tag variety enforcement.
|
||||||
|
*
|
||||||
|
* @param array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $scored
|
||||||
|
* @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
|
||||||
|
*/
|
||||||
|
private function applyDiversity(array $scored): array
|
||||||
|
{
|
||||||
|
$maxPerCreator = (int) config('recommendations.max_per_creator', 3);
|
||||||
|
$minUniqueTags = (int) config('recommendations.min_unique_tags', 5);
|
||||||
|
|
||||||
|
$creatorCount = [];
|
||||||
|
$seenTagSlugs = [];
|
||||||
|
$result = [];
|
||||||
|
$deferred = []; // items over per-creator cap (added back at end)
|
||||||
|
|
||||||
|
foreach ($scored as $item) {
|
||||||
|
$creatorId = (int) $item['artwork']->user_id;
|
||||||
|
|
||||||
|
if (($creatorCount[$creatorId] ?? 0) >= $maxPerCreator) {
|
||||||
|
$deferred[] = $item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = $item;
|
||||||
|
$creatorCount[$creatorId] = ($creatorCount[$creatorId] ?? 0) + 1;
|
||||||
|
|
||||||
|
foreach ($item['tag_slugs'] as $slug) {
|
||||||
|
$seenTagSlugs[$slug] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tag variety in first 20 – if insufficient, inject from deferred
|
||||||
|
if (count($seenTagSlugs) < $minUniqueTags && $deferred !== []) {
|
||||||
|
foreach ($deferred as $item) {
|
||||||
|
$newTags = array_diff($item['tag_slugs'], array_keys($seenTagSlugs));
|
||||||
|
if ($newTags !== []) {
|
||||||
|
$result[] = $item;
|
||||||
|
foreach ($newTags as $slug) {
|
||||||
|
$seenTagSlugs[$slug] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($seenTagSlugs) >= $minUniqueTags) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cold-start fallback ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private function coldStart(int $userId, int $limit, ?string $cursor): array
|
||||||
|
{
|
||||||
|
$offset = $this->decodeCursor($cursor);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$results = Artwork::search('')
|
||||||
|
->options([
|
||||||
|
'filter' => 'is_public = true AND is_approved = true AND author_id != ' . $userId,
|
||||||
|
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||||
|
])
|
||||||
|
->paginate(self::COLD_START_LIMIT + $offset, 'page', 1);
|
||||||
|
|
||||||
|
$artworks = $results->getCollection()->slice($offset, $limit)->values();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$artworks = Artwork::public()
|
||||||
|
->published()
|
||||||
|
->where('user_id', '!=', $userId)
|
||||||
|
->orderByDesc('trending_score_7d')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->skip($offset)
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextOffset = $offset + $limit;
|
||||||
|
$hasMore = $artworks->count() >= $limit;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $artworks->map(fn (Artwork $a): array => $this->serializeArtwork($a))->values()->all(),
|
||||||
|
'meta' => [
|
||||||
|
'source' => 'cold_start',
|
||||||
|
'cursor' => $this->encodeCursor($offset),
|
||||||
|
'next_cursor' => $hasMore ? $this->encodeCursor($nextOffset) : null,
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pagination ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $diversified
|
||||||
|
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private function paginate(array $diversified, int $limit, ?string $cursor, UserRecoProfileDTO $profile): array
|
||||||
|
{
|
||||||
|
$offset = $this->decodeCursor($cursor);
|
||||||
|
$pageItems = array_slice($diversified, $offset, $limit);
|
||||||
|
$total = count($diversified);
|
||||||
|
$nextOffset = $offset + $limit;
|
||||||
|
|
||||||
|
$data = array_map(
|
||||||
|
fn (array $item): array => array_merge(
|
||||||
|
$this->serializeArtwork($item['artwork']),
|
||||||
|
['score' => round((float) $item['score'], 5), 'source' => 'personalised']
|
||||||
|
),
|
||||||
|
$pageItems
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => array_values($data),
|
||||||
|
'meta' => [
|
||||||
|
'source' => 'personalised',
|
||||||
|
'cursor' => $this->encodeCursor($offset),
|
||||||
|
'next_cursor' => $nextOffset < $total ? $this->encodeCursor($nextOffset) : null,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total_candidates' => $total,
|
||||||
|
'has_signals' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @return array<int, int> */
|
||||||
|
private function getFavoritedIds(int $userId): array
|
||||||
|
{
|
||||||
|
return DB::table('artwork_favourites')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->pluck('artwork_id')
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializeArtwork(Artwork $artwork): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $artwork->id,
|
||||||
|
'title' => $artwork->title ?? 'Untitled',
|
||||||
|
'slug' => $artwork->slug ?? '',
|
||||||
|
'thumbnail_url' => $artwork->thumbUrl('md'),
|
||||||
|
'author' => $artwork->user?->name ?? 'Artist',
|
||||||
|
'author_id' => $artwork->user_id,
|
||||||
|
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
|
||||||
|
'width' => $artwork->width,
|
||||||
|
'height' => $artwork->height,
|
||||||
|
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeCursor(?string $cursor): int
|
||||||
|
{
|
||||||
|
if (! $cursor) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($decoded, true);
|
||||||
|
return max(0, (int) Arr::get((array) $json, 'offset', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeCursor(int $offset): string
|
||||||
|
{
|
||||||
|
$payload = json_encode(['offset' => max(0, $offset)]);
|
||||||
|
return rtrim(strtr(base64_encode((string) $payload), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
307
app/Services/Recommendation/UserPreferenceBuilder.php
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Recommendation;
|
||||||
|
|
||||||
|
use App\DTOs\UserRecoProfileDTO;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserRecoProfile;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserPreferenceBuilder
|
||||||
|
*
|
||||||
|
* Builds (and caches) a UserRecoProfileDTO for a given user by aggregating
|
||||||
|
* signals from their interaction history:
|
||||||
|
*
|
||||||
|
* Signal Weight (default)
|
||||||
|
* ───────────────────────── ────────────────
|
||||||
|
* Award given (gold/silver) +5
|
||||||
|
* Artwork favourited +3
|
||||||
|
* Reaction given +2
|
||||||
|
* Creator followed (tags) +2 for their tags
|
||||||
|
* View recorded +1
|
||||||
|
*
|
||||||
|
* The result is persisted to `user_reco_profiles` (6-hour TTL by default) so
|
||||||
|
* subsequent requests in the same window skip all DB queries.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $builder = app(UserPreferenceBuilder::class);
|
||||||
|
* $dto = $builder->build($user); // cached
|
||||||
|
* $dto = $builder->buildFresh($user); // force rebuild
|
||||||
|
*/
|
||||||
|
class UserPreferenceBuilder
|
||||||
|
{
|
||||||
|
// Redis / file cache key (short-lived insurance layer on top of DB row)
|
||||||
|
private const REDIS_TTL = 300; // 5 minutes — warm cache after DB write
|
||||||
|
|
||||||
|
public function __construct() {}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a cached profile DTO, rebuilding from DB if stale or absent.
|
||||||
|
*/
|
||||||
|
public function build(User $user): UserRecoProfileDTO
|
||||||
|
{
|
||||||
|
$cacheKey = $this->cacheKey($user->id);
|
||||||
|
|
||||||
|
// 1. Redis warm layer
|
||||||
|
/** @var array<string,mixed>|null $cached */
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return UserRecoProfileDTO::fromArray($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Persistent DB row
|
||||||
|
$row = UserRecoProfile::find($user->id);
|
||||||
|
if ($row !== null && $row->isFresh()) {
|
||||||
|
$dto = $row->toDTO();
|
||||||
|
Cache::put($cacheKey, $dto->toArray(), self::REDIS_TTL);
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rebuild
|
||||||
|
return $this->buildFresh($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a full rebuild from source tables, persist and cache the result.
|
||||||
|
*/
|
||||||
|
public function buildFresh(User $user): UserRecoProfileDTO
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$dto = $this->compute($user);
|
||||||
|
$this->persist($user->id, $dto);
|
||||||
|
Cache::put($this->cacheKey($user->id), $dto->toArray(), self::REDIS_TTL);
|
||||||
|
return $dto;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('UserPreferenceBuilder: failed to build profile', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return new UserRecoProfileDTO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate only the Redis warm layer (DB row stays intact until TTL).
|
||||||
|
*/
|
||||||
|
public function invalidate(int $userId): void
|
||||||
|
{
|
||||||
|
Cache::forget($this->cacheKey($userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Computation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function compute(User $user): UserRecoProfileDTO
|
||||||
|
{
|
||||||
|
$sigWeights = (array) config('recommendations.signal_weights', []);
|
||||||
|
$wAward = (float) ($sigWeights['award'] ?? 5.0);
|
||||||
|
$wFav = (float) ($sigWeights['favorite'] ?? 3.0);
|
||||||
|
$wFollow = (float) ($sigWeights['follow'] ?? 2.0);
|
||||||
|
|
||||||
|
$tagLimit = (int) config('recommendations.profile.top_tags_limit', 20);
|
||||||
|
$catLimit = (int) config('recommendations.profile.top_categories_limit', 5);
|
||||||
|
$creatorLimit = (int) config('recommendations.profile.strong_creators_limit', 50);
|
||||||
|
|
||||||
|
// ── 1. Tag scores from favourited artworks ────────────────────────────
|
||||||
|
$tagRaw = $this->tagScoresFromFavourites($user->id, $wFav);
|
||||||
|
|
||||||
|
// ── 2. Tag scores from awards given ──────────────────────────────────
|
||||||
|
foreach ($this->tagScoresFromAwards($user->id, $wAward) as $slug => $score) {
|
||||||
|
$tagRaw[$slug] = ($tagRaw[$slug] ?? 0.0) + $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Creator IDs from follows (top N) ───────────────────────────────
|
||||||
|
$followedIds = $this->followedCreatorIds($user->id, $creatorLimit);
|
||||||
|
|
||||||
|
// ── 4. Tag scores lifted from followed creators' recent works ─────────
|
||||||
|
if ($followedIds !== []) {
|
||||||
|
foreach ($this->tagScoresFromFollowedCreators($followedIds, $wFollow) as $slug => $score) {
|
||||||
|
$tagRaw[$slug] = ($tagRaw[$slug] ?? 0.0) + $score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Category scores from favourited artworks ───────────────────────
|
||||||
|
$catRaw = $this->categoryScoresFromFavourites($user->id, $wFav);
|
||||||
|
|
||||||
|
// Sort descending and take top N
|
||||||
|
arsort($tagRaw);
|
||||||
|
arsort($catRaw);
|
||||||
|
|
||||||
|
$topTagSlugs = array_keys(array_slice($tagRaw, 0, $tagLimit));
|
||||||
|
$topCatSlugs = array_keys(array_slice($catRaw, 0, $catLimit));
|
||||||
|
|
||||||
|
$tagWeights = $this->normalise($tagRaw);
|
||||||
|
$catWeights = $this->normalise($catRaw);
|
||||||
|
|
||||||
|
return new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: $topTagSlugs,
|
||||||
|
topCategorySlugs: $topCatSlugs,
|
||||||
|
strongCreatorIds: $followedIds,
|
||||||
|
tagWeights: $tagWeights,
|
||||||
|
categoryWeights: $catWeights,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Signal collectors ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, float> slug → raw score
|
||||||
|
*/
|
||||||
|
private function tagScoresFromFavourites(int $userId, float $weight): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('artwork_favourites as af')
|
||||||
|
->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id')
|
||||||
|
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||||
|
->where('af.user_id', $userId)
|
||||||
|
->where('t.is_active', true)
|
||||||
|
->selectRaw('t.slug, COUNT(*) as cnt')
|
||||||
|
->groupBy('t.id', 't.slug')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$scores = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$scores[(string) $row->slug] = (float) $row->cnt * $weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private function tagScoresFromAwards(int $userId, float $weight): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('artwork_awards as aa')
|
||||||
|
->join('artwork_tag as at', 'at.artwork_id', '=', 'aa.artwork_id')
|
||||||
|
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||||
|
->where('aa.user_id', $userId)
|
||||||
|
->where('t.is_active', true)
|
||||||
|
->selectRaw('t.slug, SUM(aa.weight) as total_weight')
|
||||||
|
->groupBy('t.id', 't.slug')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$scores = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$scores[(string) $row->slug] = (float) $row->total_weight * $weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $creatorIds
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private function tagScoresFromFollowedCreators(array $creatorIds, float $weight): array
|
||||||
|
{
|
||||||
|
if ($creatorIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample recent artworks to avoid full scan
|
||||||
|
$rows = DB::table('artworks as a')
|
||||||
|
->join('artwork_tag as at', 'at.artwork_id', '=', 'a.id')
|
||||||
|
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||||
|
->whereIn('a.user_id', $creatorIds)
|
||||||
|
->where('a.is_public', true)
|
||||||
|
->where('a.is_approved', true)
|
||||||
|
->where('t.is_active', true)
|
||||||
|
->whereNull('a.deleted_at')
|
||||||
|
->orderByDesc('a.published_at')
|
||||||
|
->limit(500)
|
||||||
|
->selectRaw('t.slug, COUNT(*) as cnt')
|
||||||
|
->groupBy('t.id', 't.slug')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$scores = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$scores[(string) $row->slug] = (float) $row->cnt * $weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private function categoryScoresFromFavourites(int $userId, float $weight): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('artwork_favourites as af')
|
||||||
|
->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id')
|
||||||
|
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||||
|
->where('af.user_id', $userId)
|
||||||
|
->whereNull('c.deleted_at')
|
||||||
|
->selectRaw('c.slug, COUNT(*) as cnt')
|
||||||
|
->groupBy('c.id', 'c.slug')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$scores = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$scores[(string) $row->slug] = (float) $row->cnt * $weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function followedCreatorIds(int $userId, int $limit): array
|
||||||
|
{
|
||||||
|
return DB::table('user_followers')
|
||||||
|
->where('follower_id', $userId)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->pluck('user_id')
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a raw score map to sum 1.0.
|
||||||
|
*
|
||||||
|
* @param array<string, float> $raw
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private function normalise(array $raw): array
|
||||||
|
{
|
||||||
|
$sum = array_sum($raw);
|
||||||
|
if ($sum <= 0.0) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(fn (float $v): float => round($v / $sum, 6), $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheKey(int $userId): string
|
||||||
|
{
|
||||||
|
return "user_reco_profile:{$userId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function persist(int $userId, UserRecoProfileDTO $dto): void
|
||||||
|
{
|
||||||
|
$data = $dto->toArray();
|
||||||
|
|
||||||
|
UserRecoProfile::query()->updateOrCreate(
|
||||||
|
['user_id' => $userId],
|
||||||
|
[
|
||||||
|
'top_tags_json' => $data['top_tags'],
|
||||||
|
'top_categories_json' => $data['top_categories'],
|
||||||
|
'followed_creator_ids_json' => $data['strong_creators'],
|
||||||
|
'tag_weights_json' => $data['tag_weights'],
|
||||||
|
'category_weights_json' => $data['category_weights'],
|
||||||
|
'disliked_tag_ids_json' => $data['disliked_tags'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,61 @@ return [
|
|||||||
// Uses same queue family as vision jobs by default; keeps embedding work async and non-blocking.
|
// Uses same queue family as vision jobs by default; keeps embedding work async and non-blocking.
|
||||||
'queue' => env('RECOMMENDATIONS_QUEUE', env('VISION_QUEUE', 'default')),
|
'queue' => env('RECOMMENDATIONS_QUEUE', env('VISION_QUEUE', 'default')),
|
||||||
|
|
||||||
|
// ─── Phase 1 "For You" feed scoring weights ───────────────────────────────
|
||||||
|
// Influences the PHP reranking pass after Meilisearch candidate retrieval.
|
||||||
|
// Tweak here without code changes.
|
||||||
|
'weights' => [
|
||||||
|
// Tag overlap score weight (0–1 normalized overlap fraction)
|
||||||
|
'tag_overlap' => (float) env('RECO_W_TAG_OVERLAP', 0.40),
|
||||||
|
// Creator affinity score weight (1.0 if followed, 0 otherwise)
|
||||||
|
'creator_affinity' => (float) env('RECO_W_CREATOR_AFFINITY', 0.25),
|
||||||
|
// Popularity boost (log-normalised views/downloads)
|
||||||
|
'popularity' => (float) env('RECO_W_POPULARITY', 0.20),
|
||||||
|
// Freshness boost (exponential decay over 30 days)
|
||||||
|
'freshness' => (float) env('RECO_W_FRESHNESS', 0.15),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── User preference signal weights ──────────────────────────────────────
|
||||||
|
// How much each user action contributes to building the reco profile.
|
||||||
|
'signal_weights' => [
|
||||||
|
'award' => (float) env('RECO_SIG_AWARD', 5.0),
|
||||||
|
'favorite' => (float) env('RECO_SIG_FAVORITE', 3.0),
|
||||||
|
'reaction' => (float) env('RECO_SIG_REACTION', 2.0),
|
||||||
|
'view' => (float) env('RECO_SIG_VIEW', 1.0),
|
||||||
|
'follow' => (float) env('RECO_SIG_FOLLOW', 2.0),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Candidate generation ──────────────────────────────────────────────────
|
||||||
|
// How many Meilisearch candidates to fetch before PHP reranking.
|
||||||
|
'candidate_pool_size' => (int) env('RECO_CANDIDATE_POOL', 200),
|
||||||
|
|
||||||
|
// ─── Diversity controls ────────────────────────────────────────────────────
|
||||||
|
// Maximum artworks per creator allowed in a single page of results.
|
||||||
|
'max_per_creator' => (int) env('RECO_MAX_PER_CREATOR', 3),
|
||||||
|
// Minimum distinct tag count in first 20 feed results.
|
||||||
|
'min_unique_tags' => (int) env('RECO_MIN_UNIQUE_TAGS', 5),
|
||||||
|
|
||||||
|
// ─── TTLs (seconds) ────────────────────────────────────────────────────────
|
||||||
|
'ttl' => [
|
||||||
|
// User reco profile cache (tag/creator affinity data)
|
||||||
|
'user_reco_profile' => (int) env('RECO_TTL_PROFILE', 6 * 3600),
|
||||||
|
// For You paginated results cache
|
||||||
|
'for_you_feed' => (int) env('RECO_TTL_FOR_YOU', 5 * 60),
|
||||||
|
// Similar artworks per artwork
|
||||||
|
'similar_artworks' => (int) env('RECO_TTL_SIMILAR', 30 * 60),
|
||||||
|
// Suggested creators per user
|
||||||
|
'creator_suggestions' => (int) env('RECO_TTL_CREATORS', 30 * 60),
|
||||||
|
// Suggested tags per user
|
||||||
|
'tag_suggestions' => (int) env('RECO_TTL_TAGS', 60 * 60),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Profile limits ────────────────────────────────────────────────────────
|
||||||
|
'profile' => [
|
||||||
|
'top_tags_limit' => (int) env('RECO_PROFILE_TAGS', 20),
|
||||||
|
'top_categories_limit' => (int) env('RECO_PROFILE_CATS', 5),
|
||||||
|
'strong_creators_limit' => (int) env('RECO_PROFILE_CREATORS', 50),
|
||||||
|
],
|
||||||
|
|
||||||
'embedding' => [
|
'embedding' => [
|
||||||
'enabled' => env('RECOMMENDATIONS_EMBEDDING_ENABLED', true),
|
'enabled' => env('RECOMMENDATIONS_EMBEDDING_ENABLED', true),
|
||||||
'model' => env('RECOMMENDATIONS_EMBEDDING_MODEL', 'clip'),
|
'model' => env('RECOMMENDATIONS_EMBEDDING_MODEL', 'clip'),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 recommendation engine: user preference cache table.
|
||||||
|
*
|
||||||
|
* Stores pre-computed tag/category/creator affinity profiles so that
|
||||||
|
* the "For You" feed can be generated without heavy joins on every request.
|
||||||
|
*
|
||||||
|
* TTL enforcement: the application checks `updated_at` + config TTL to decide
|
||||||
|
* whether to rebuild. A background job (or on-demand compute) refreshes stale rows.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_reco_profiles', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('user_id')->primary();
|
||||||
|
$table->json('top_tags_json')->nullable()->comment('Top tag slugs ordered by weighted score (up to 20)');
|
||||||
|
$table->json('top_categories_json')->nullable()->comment('Top category slugs ordered by weight (up to 5)');
|
||||||
|
$table->json('followed_creator_ids_json')->nullable()->comment('Followed creator IDs (up to 50)');
|
||||||
|
$table->json('tag_weights_json')->nullable()->comment('Normalised tag slug → weight map');
|
||||||
|
$table->json('category_weights_json')->nullable()->comment('Normalised category slug → weight map');
|
||||||
|
$table->json('disliked_tag_ids_json')->nullable()->comment('Hidden/blocked tag slugs (future use)');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('user_id')
|
||||||
|
->references('id')
|
||||||
|
->on('users')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->index('updated_at', 'urp_updated_at_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_reco_profiles');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.zip
Normal file
BIN
public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
public/favicon/favicon.svg
Normal file
|
After Width: | Height: | Size: 747 KiB |
21
public/favicon/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Skinbase",
|
||||||
|
"short_name": "Skinbase",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#0F1724",
|
||||||
|
"background_color": "#0F1724",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
163
resources/js/components/gallery/ArtworkCard.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
function buildAvatarUrl(userId, avatarHash, size = 40) {
|
||||||
|
if (!userId) return '/images/avatar-placeholder.jpg';
|
||||||
|
if (!avatarHash) return `/avatar/default/${userId}?s=${size}`;
|
||||||
|
return `/avatar/${userId}/${avatarHash}?s=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str) {
|
||||||
|
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React version of resources/views/components/artwork-card.blade.php
|
||||||
|
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
|
||||||
|
*/
|
||||||
|
export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) {
|
||||||
|
const imgRef = useRef(null);
|
||||||
|
|
||||||
|
// Activate blur-preview class once image has decoded (mirrors nova.js behaviour)
|
||||||
|
useEffect(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
const markLoaded = () => img.classList.add('is-loaded');
|
||||||
|
if (img.complete && img.naturalWidth > 0) { markLoaded(); return; }
|
||||||
|
img.addEventListener('load', markLoaded, { once: true });
|
||||||
|
img.addEventListener('error', markLoaded, { once: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const title = (art.name || art.title || 'Untitled artwork').trim();
|
||||||
|
const author = (art.uname || art.author_name || art.author || 'Skinbase').trim();
|
||||||
|
const username = (art.username || art.uname || '').trim();
|
||||||
|
const category = (art.category_name || art.category || '').trim();
|
||||||
|
|
||||||
|
const likes = art.likes ?? art.favourites ?? 0;
|
||||||
|
const comments = art.comments_count ?? art.comment_count ?? 0;
|
||||||
|
|
||||||
|
const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg';
|
||||||
|
const imgSrcset = art.thumb_srcset || imgSrc;
|
||||||
|
|
||||||
|
const cardUrl = art.url || (art.id ? `/art/${art.id}/${slugify(title)}` : '#');
|
||||||
|
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
|
||||||
|
const avatarSrc = buildAvatarUrl(art.user_id, art.avatar_hash, 40);
|
||||||
|
|
||||||
|
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||||||
|
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||||||
|
|
||||||
|
// Span 2 columns for panoramic images (AR > 2.0) in Photography or Wallpapers categories.
|
||||||
|
// These slugs match the root categories; name-matching is kept as fallback.
|
||||||
|
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
|
||||||
|
const wideCategoryNames = ['photography', 'wallpapers'];
|
||||||
|
const catSlug = (art.category_slug || '').toLowerCase();
|
||||||
|
const catName = (art.category_name || '').toLowerCase();
|
||||||
|
const isWideEligible =
|
||||||
|
aspectRatio !== null &&
|
||||||
|
aspectRatio > 2.0 &&
|
||||||
|
(wideCategories.includes(catSlug) || wideCategoryNames.includes(catName));
|
||||||
|
|
||||||
|
const articleStyle = isWideEligible ? { gridColumn: 'span 2' } : {};
|
||||||
|
const aspectStyle = hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : {};
|
||||||
|
// Image always fills the container absolutely – the container's height is
|
||||||
|
// driven by aspect-ratio (capped by CSS max-height). Using absolute
|
||||||
|
// positioning means width/height are always 100% of the capped box, so
|
||||||
|
// object-cover crops top/bottom instead of leaving dark gaps.
|
||||||
|
const imgClass = [
|
||||||
|
'absolute inset-0 h-full w-full object-cover',
|
||||||
|
'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]',
|
||||||
|
loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const metaParts = [];
|
||||||
|
if (art.resolution) metaParts.push(art.resolution);
|
||||||
|
else if (hasDimensions) metaParts.push(`${art.width}×${art.height}`);
|
||||||
|
if (category) metaParts.push(category);
|
||||||
|
if (art.license) metaParts.push(art.license);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`nova-card gallery-item artwork${isWideEligible ? ' nova-card--wide' : ''}`}
|
||||||
|
style={articleStyle}
|
||||||
|
data-art-id={art.id}
|
||||||
|
data-art-url={cardUrl}
|
||||||
|
data-art-title={title}
|
||||||
|
data-art-img={imgSrc}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={cardUrl}
|
||||||
|
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||||
|
>
|
||||||
|
{category && (
|
||||||
|
<div className="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* nova-card-media: height driven by aspect-ratio, capped by MasonryGallery.css max-height.
|
||||||
|
w-full prevents browsers shrinking the width when max-height overrides aspect-ratio. */}
|
||||||
|
<div
|
||||||
|
className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
|
||||||
|
style={aspectStyle}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={imgSrc}
|
||||||
|
srcSet={imgSrcset}
|
||||||
|
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||||
|
loading={loading}
|
||||||
|
decoding={loading === 'eager' ? 'sync' : 'async'}
|
||||||
|
fetchPriority={fetchpriority || undefined}
|
||||||
|
alt={title}
|
||||||
|
width={hasDimensions ? art.width : undefined}
|
||||||
|
height={hasDimensions ? art.height : undefined}
|
||||||
|
className={imgClass}
|
||||||
|
data-blur-preview={loading !== 'eager' ? '' : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hover badge row */}
|
||||||
|
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
|
||||||
|
View
|
||||||
|
</span>
|
||||||
|
{authorUrl && (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
|
||||||
|
Profile
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay caption */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||||
|
<div className="truncate text-sm font-semibold text-white">{title}</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
|
||||||
|
<span className="truncate flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={`Avatar of ${author}`}
|
||||||
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
<span>{author}</span>
|
||||||
|
{username && (
|
||||||
|
<span className="text-white/60"> @{username}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0">❤ {likes} · 💬 {comments}</span>
|
||||||
|
</div>
|
||||||
|
{metaParts.length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] text-white/70">
|
||||||
|
{metaParts.join(' • ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="sr-only">{title} by {author}</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
resources/js/components/gallery/MasonryGallery.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* MasonryGallery – scoped CSS
|
||||||
|
*
|
||||||
|
* Grid column definitions (activated when React adds .is-enhanced to the root).
|
||||||
|
* Mirrors the blade @push('styles') blocks so the same rules apply whether the
|
||||||
|
* page is rendered server-side or by the React component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Masonry grid ─────────────────────────────────────────────────────────── */
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 8px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||||
|
|
||||||
|
/* ── Fallback aspect-ratio for cards without stored dimensions ───────────── */
|
||||||
|
/*
|
||||||
|
* When ArtworkCard has no width/height data it renders the img as h-auto,
|
||||||
|
* meaning the container height is 0 until the image loads. Setting a
|
||||||
|
* default aspect-ratio here reserves approximate space immediately and
|
||||||
|
* prevents applyMasonry from calculating span=1 → then jumping on load.
|
||||||
|
* Cards with an inline aspect-ratio style (from real dimensions) override this.
|
||||||
|
*/
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
width: 100%; /* prevent aspect-ratio + max-height from shrinking the column width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override: when an inline aspect-ratio is set by ArtworkCard those values */
|
||||||
|
/* take precedence naturally (inline style > class). No extra selector needed. */
|
||||||
|
|
||||||
|
/* ── Card max-height cap ──────────────────────────────────────────────────── */
|
||||||
|
/*
|
||||||
|
* Limits any single card to the height of 2 stacked 16:9 images in its column.
|
||||||
|
* Formula: 2 × (col_width × 9/16) = col_width × 9/8
|
||||||
|
*
|
||||||
|
* 5-col (lg+): col_width = (100vw - 80px_padding - 4×24px_gaps) / 5
|
||||||
|
* = (100vw - 176px) / 5
|
||||||
|
* max-height = (100vw - 176px) / 5 × 9/8
|
||||||
|
* = (100vw - 176px) × 0.225
|
||||||
|
*
|
||||||
|
* 2-col (md): col_width = (100vw - 80px - 1×24px) / 2
|
||||||
|
* = (100vw - 104px) / 2
|
||||||
|
* max-height = (100vw - 104px) / 2 × 9/8
|
||||||
|
* = (100vw - 104px) × 0.5625
|
||||||
|
*
|
||||||
|
* 1-col mobile: uncapped – portrait images are fine filling the full width.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Global selector covers both the React-rendered gallery and the blade fallback */
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||||
|
overflow: hidden; /* ensure img is clipped at max-height */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||||
|
/* 5-column layout: 2 × (col_width × 9/16) = col_width × 9/8 */
|
||||||
|
max-height: calc((100vw - 176px) * 9 / 40);
|
||||||
|
}
|
||||||
|
/* Wide (2-col spanning) cards get double the column width */
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
|
||||||
|
max-height: calc((100vw - 176px) * 9 / 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||||
|
/* 2-column layout */
|
||||||
|
max-height: calc((100vw - 104px) * 9 / 16);
|
||||||
|
}
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
|
||||||
|
/* 2-col span fills full width on md breakpoint */
|
||||||
|
max-height: calc((100vw - 104px) * 9 / 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image is positioned absolutely inside the container so it always fills
|
||||||
|
the capped box (max-height), cropping top/bottom via object-fit: cover. */
|
||||||
|
[data-nova-gallery] [data-gallery-grid] .nova-card-media img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||||
|
.nova-skeleton-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
min-height: 180px;
|
||||||
|
background: linear-gradient(
|
||||||
|
110deg,
|
||||||
|
rgba(255, 255, 255, 0.06) 8%,
|
||||||
|
rgba(255, 255, 255, 0.12) 18%,
|
||||||
|
rgba(255, 255, 255, 0.06) 33%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: novaShimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes novaShimmer {
|
||||||
|
to { background-position-x: -200%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card enter animation (appended by infinite scroll) ───────────────────── */
|
||||||
|
.nova-card-enter { opacity: 0; transform: translateY(8px); }
|
||||||
|
.nova-card-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
||||||
|
}
|
||||||
277
resources/js/components/gallery/MasonryGallery.jsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import React, {
|
||||||
|
useState, useEffect, useRef, useCallback, memo,
|
||||||
|
} from 'react';
|
||||||
|
import ArtworkCard from './ArtworkCard';
|
||||||
|
import './MasonryGallery.css';
|
||||||
|
|
||||||
|
// ── Masonry helpers ────────────────────────────────────────────────────────
|
||||||
|
const ROW_SIZE = 8;
|
||||||
|
const ROW_GAP = 16;
|
||||||
|
|
||||||
|
function applyMasonry(grid) {
|
||||||
|
if (!grid) return;
|
||||||
|
Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => {
|
||||||
|
const media = card.querySelector('.nova-card-media') || card;
|
||||||
|
let height = media.getBoundingClientRect().height || 200;
|
||||||
|
|
||||||
|
// Clamp to the computed max-height so the span never over-reserves rows
|
||||||
|
// when CSS max-height kicks in (e.g. portrait images capped to 2×16:9).
|
||||||
|
const cssMaxH = parseFloat(getComputedStyle(media).maxHeight);
|
||||||
|
if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) {
|
||||||
|
height = cssMaxH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP)));
|
||||||
|
card.style.gridRowEnd = `span ${span}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForImages(el) {
|
||||||
|
return Promise.all(
|
||||||
|
Array.from(el.querySelectorAll('img')).map((img) =>
|
||||||
|
img.decode ? img.decode().catch(() => null) : Promise.resolve(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page fetch helpers ─────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Fetch the next page of data.
|
||||||
|
*
|
||||||
|
* The response is either:
|
||||||
|
* - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is
|
||||||
|
* sent and the controller returns JSON (future enhancement)
|
||||||
|
* - HTML page – we parse [data-react-masonry-gallery] from it and read its
|
||||||
|
* data-artworks / data-next-cursor / data-next-page-url attributes.
|
||||||
|
*/
|
||||||
|
async function fetchPageData(url) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
// JSON fast-path (if controller ever returns JSON)
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
const json = await res.json();
|
||||||
|
return {
|
||||||
|
artworks: json.artworks ?? [],
|
||||||
|
nextCursor: json.next_cursor ?? null,
|
||||||
|
nextPageUrl: json.next_page_url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML: parse and extract mount-container data attributes
|
||||||
|
const html = await res.text();
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const el = doc.querySelector('[data-react-masonry-gallery]');
|
||||||
|
if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null };
|
||||||
|
|
||||||
|
let artworks = [];
|
||||||
|
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
|
||||||
|
|
||||||
|
return {
|
||||||
|
artworks,
|
||||||
|
nextCursor: el.dataset.nextCursor || null,
|
||||||
|
nextPageUrl: el.dataset.nextPageUrl || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skeleton row ──────────────────────────────────────────────────────────
|
||||||
|
function SkeletonCard() {
|
||||||
|
return <div className="nova-skeleton-card" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKELETON_COUNT = 10;
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* MasonryGallery
|
||||||
|
*
|
||||||
|
* Props (all optional – set via data attributes in entry-masonry-gallery.jsx):
|
||||||
|
* artworks [] Initial artwork objects
|
||||||
|
* galleryType string Maps to data-gallery-type (e.g. 'trending')
|
||||||
|
* cursorEndpoint string|null Route for cursor-based feeds (e.g. For You)
|
||||||
|
* initialNextCursor string|null First cursor token
|
||||||
|
* initialNextPageUrl string|null First "next page" URL (page-based feeds)
|
||||||
|
* limit number Items per page (default 40)
|
||||||
|
*/
|
||||||
|
function MasonryGallery({
|
||||||
|
artworks: initialArtworks = [],
|
||||||
|
galleryType = 'discover',
|
||||||
|
cursorEndpoint = null,
|
||||||
|
initialNextCursor = null,
|
||||||
|
initialNextPageUrl = null,
|
||||||
|
limit = 40,
|
||||||
|
}) {
|
||||||
|
const [artworks, setArtworks] = useState(initialArtworks);
|
||||||
|
const [nextCursor, setNextCursor] = useState(initialNextCursor);
|
||||||
|
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
|
||||||
|
|
||||||
|
const gridRef = useRef(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Masonry re-layout ──────────────────────────────────────────────────
|
||||||
|
const relayout = useCallback(() => {
|
||||||
|
const g = gridRef.current;
|
||||||
|
if (!g) return;
|
||||||
|
applyMasonry(g);
|
||||||
|
waitForImages(g).then(() => applyMasonry(g));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-layout whenever artworks list changes.
|
||||||
|
// Defer by one requestAnimationFrame so the browser has resolved
|
||||||
|
// aspect-ratio heights before we measure with getBoundingClientRect().
|
||||||
|
useEffect(() => {
|
||||||
|
const raf = requestAnimationFrame(() => relayout());
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [artworks, relayout]);
|
||||||
|
|
||||||
|
// Re-layout on container resize (column width changes)
|
||||||
|
useEffect(() => {
|
||||||
|
const g = gridRef.current;
|
||||||
|
if (!g || !('ResizeObserver' in window)) return;
|
||||||
|
const ro = new ResizeObserver(relayout);
|
||||||
|
ro.observe(g);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [relayout]);
|
||||||
|
|
||||||
|
// ── Load more ──────────────────────────────────────────────────────────
|
||||||
|
const fetchNext = useCallback(async () => {
|
||||||
|
if (loading || done) return;
|
||||||
|
|
||||||
|
// Build the URL to fetch
|
||||||
|
let fetchUrl = null;
|
||||||
|
if (cursorEndpoint && nextCursor) {
|
||||||
|
const u = new URL(cursorEndpoint, window.location.href);
|
||||||
|
u.searchParams.set('cursor', nextCursor);
|
||||||
|
u.searchParams.set('limit', String(limit));
|
||||||
|
fetchUrl = u.toString();
|
||||||
|
} else if (nextPageUrl) {
|
||||||
|
fetchUrl = nextPageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fetchUrl) { setDone(true); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { artworks: newItems, nextCursor: nc, nextPageUrl: np } =
|
||||||
|
await fetchPageData(fetchUrl);
|
||||||
|
|
||||||
|
if (!newItems.length) {
|
||||||
|
setDone(true);
|
||||||
|
} else {
|
||||||
|
setArtworks((prev) => [...prev, ...newItems]);
|
||||||
|
if (cursorEndpoint) {
|
||||||
|
setNextCursor(nc);
|
||||||
|
if (!nc) setDone(true);
|
||||||
|
} else {
|
||||||
|
setNextPageUrl(np);
|
||||||
|
if (!np) setDone(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDone(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loading, done, cursorEndpoint, nextCursor, nextPageUrl, limit]);
|
||||||
|
|
||||||
|
// ── Intersection observer for infinite scroll ──────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (done) return;
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!trigger || !('IntersectionObserver' in window)) return;
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => { if (entries[0].isIntersecting) fetchNext(); },
|
||||||
|
{ rootMargin: '900px', threshold: 0 },
|
||||||
|
);
|
||||||
|
io.observe(trigger);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [done, fetchNext]);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="px-6 pb-10 pt-2 md:px-10 is-enhanced"
|
||||||
|
data-nova-gallery
|
||||||
|
data-gallery-type={galleryType}
|
||||||
|
data-react-masonry-gallery
|
||||||
|
data-artworks={JSON.stringify(artworks)}
|
||||||
|
data-next-cursor={nextCursor ?? undefined}
|
||||||
|
data-next-page-url={nextPageUrl ?? undefined}
|
||||||
|
>
|
||||||
|
{artworks.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5"
|
||||||
|
data-gallery-grid
|
||||||
|
>
|
||||||
|
{artworks.map((art, idx) => (
|
||||||
|
<ArtworkCard
|
||||||
|
key={`${art.id}-${idx}`}
|
||||||
|
art={art}
|
||||||
|
loading={idx < 8 ? 'eager' : 'lazy'}
|
||||||
|
fetchpriority={idx === 0 ? 'high' : null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite scroll sentinel – placed after the grid */}
|
||||||
|
{!done && (
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className="h-px w-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center items-center gap-2 mt-8 py-4 text-white/30 text-sm">
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4 shrink-0"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12" cy="12" r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8v8H4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Loading more…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{done && artworks.length > 0 && (
|
||||||
|
<p className="text-center text-xs text-white/20 mt-8 py-2">
|
||||||
|
All caught up
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Empty state – gallery-type-specific messaging handled by caller */
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||||
|
<p className="text-white/40 text-sm">No artworks found for this section yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MasonryGallery);
|
||||||
53
resources/js/entry-masonry-gallery.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Entry point for the MasonryGallery React component.
|
||||||
|
*
|
||||||
|
* Looks for every element with [data-react-masonry-gallery] on the page and
|
||||||
|
* mounts a MasonryGallery instance into it. All configuration is passed via
|
||||||
|
* data attributes so the Blade view does not need to know anything about React.
|
||||||
|
*
|
||||||
|
* Expected data attributes on the mount element:
|
||||||
|
* data-artworks JSON array of artwork objects (required)
|
||||||
|
* data-gallery-type e.g. "trending", "for-you" (default: "discover")
|
||||||
|
* data-cursor-endpoint URL for cursor-based feeds (optional)
|
||||||
|
* data-next-cursor Initial cursor token (optional)
|
||||||
|
* data-next-page-url Initial "next page" URL (optional)
|
||||||
|
* data-limit Items per page (default: 40)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import MasonryGallery from './components/gallery/MasonryGallery';
|
||||||
|
|
||||||
|
function mountAll() {
|
||||||
|
document
|
||||||
|
.querySelectorAll('[data-react-masonry-gallery]')
|
||||||
|
.forEach((container) => {
|
||||||
|
// Already mounted by a previous call (e.g. HMR)
|
||||||
|
if (container.dataset.reactMounted) return;
|
||||||
|
container.dataset.reactMounted = '1';
|
||||||
|
|
||||||
|
let artworks = [];
|
||||||
|
try {
|
||||||
|
artworks = JSON.parse(container.dataset.artworks || '[]');
|
||||||
|
} catch {
|
||||||
|
console.warn('[MasonryGallery] Could not parse data-artworks JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
artworks,
|
||||||
|
galleryType: container.dataset.galleryType || 'discover',
|
||||||
|
cursorEndpoint: container.dataset.cursorEndpoint || null,
|
||||||
|
initialNextCursor: container.dataset.nextCursor || null,
|
||||||
|
initialNextPageUrl: container.dataset.nextPageUrl || null,
|
||||||
|
limit: parseInt(container.dataset.limit || '40', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
createRoot(container).render(<MasonryGallery {...props} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', mountAll);
|
||||||
|
} else {
|
||||||
|
mountAll();
|
||||||
|
}
|
||||||
110
resources/views/layouts/_navigation.blade.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
||||||
|
<!-- Primary Navigation Menu -->
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="shrink-0 flex items-center">
|
||||||
|
<a href="{{ route('dashboard') }}">
|
||||||
|
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Links -->
|
||||||
|
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||||
|
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||||
|
{{ __('Dashboard') }}
|
||||||
|
</x-nav-link>
|
||||||
|
@auth
|
||||||
|
<x-nav-link :href="route('discover.for-you')" :active="request()->routeIs('discover.for-you')">
|
||||||
|
{{ __('For You') }}
|
||||||
|
</x-nav-link>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Dropdown -->
|
||||||
|
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||||
|
<x-dropdown align="right" width="48">
|
||||||
|
<x-slot name="trigger">
|
||||||
|
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
||||||
|
<div>{{ Auth::user()->name }}</div>
|
||||||
|
|
||||||
|
<div class="ms-1">
|
||||||
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<x-slot name="content">
|
||||||
|
<x-dropdown-link :href="route('profile.edit')">
|
||||||
|
{{ __('Profile') }}
|
||||||
|
</x-dropdown-link>
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-dropdown-link :href="route('logout')"
|
||||||
|
onclick="event.preventDefault();
|
||||||
|
this.closest('form').submit();">
|
||||||
|
{{ __('Log Out') }}
|
||||||
|
</x-dropdown-link>
|
||||||
|
</form>
|
||||||
|
</x-slot>
|
||||||
|
</x-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hamburger -->
|
||||||
|
<div class="-me-2 flex items-center sm:hidden">
|
||||||
|
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
|
||||||
|
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Responsive Navigation Menu -->
|
||||||
|
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||||
|
<div class="pt-2 pb-3 space-y-1">
|
||||||
|
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||||
|
{{ __('Dashboard') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
@auth
|
||||||
|
<x-responsive-nav-link :href="route('discover.for-you')" :active="request()->routeIs('discover.for-you')">
|
||||||
|
{{ __('For You') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Responsive Settings Options -->
|
||||||
|
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||||
|
<div class="px-4">
|
||||||
|
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
||||||
|
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<x-responsive-nav-link :href="route('profile.edit')">
|
||||||
|
{{ __('Profile') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-responsive-nav-link :href="route('logout')"
|
||||||
|
onclick="event.preventDefault();
|
||||||
|
this.closest('form').submit();">
|
||||||
|
{{ __('Log Out') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -26,7 +26,13 @@
|
|||||||
|
|
||||||
<!-- Icons (kept for now to preserve current visual output) -->
|
<!-- Icons (kept for now to preserve current visual output) -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico">
|
|
||||||
|
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
|
|
||||||
|
|
||||||
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js','resources/js/entry-search.jsx'])
|
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js','resources/js/entry-search.jsx'])
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
|
||||||
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
|
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
|
||||||
</a>
|
</a>
|
||||||
|
@auth
|
||||||
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('discover.for-you') }}">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You
|
||||||
|
</a>
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,6 +153,12 @@
|
|||||||
|
|
||||||
<!-- Notification icons -->
|
<!-- Notification icons -->
|
||||||
<div class="hidden md:flex items-center gap-1 text-soft">
|
<div class="hidden md:flex items-center gap-1 text-soft">
|
||||||
|
<a href="{{ route('discover.for-you') }}"
|
||||||
|
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg transition-colors {{ request()->routeIs('discover.for-you') ? 'bg-yellow-500/15 text-yellow-300' : 'hover:bg-white/5' }}"
|
||||||
|
title="For You">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles w-5 h-5 text-[1.1rem] {{ request()->routeIs('discover.for-you') ? 'text-yellow-300' : 'text-sb-muted' }}"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="{{ route('dashboard.favorites') }}"
|
<a href="{{ route('dashboard.favorites') }}"
|
||||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||||
title="Favourites">
|
title="Favourites">
|
||||||
@@ -316,6 +327,9 @@
|
|||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
|
||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
|
||||||
|
@auth
|
||||||
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-yellow-400/70"></i>For You</a>
|
||||||
|
@endauth
|
||||||
|
|
||||||
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Browse</div>
|
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Browse</div>
|
||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
|
||||||
|
|||||||
114
resources/views/web/discover/for-you.blade.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{-- ── Hero header ── --}}
|
||||||
|
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
|
||||||
|
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-yellow-400 text-2xl"></i>
|
||||||
|
For You
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-white/50">Artworks picked for you based on your taste.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Section switcher pills --}}
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<a href="{{ route('discover.for-you') }}"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-yellow-500/20 text-yellow-300 border border-yellow-400/20">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-xs"></i>
|
||||||
|
For You
|
||||||
|
</a>
|
||||||
|
@php
|
||||||
|
$sections = [
|
||||||
|
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||||
|
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
|
||||||
|
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
|
||||||
|
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
@foreach($sections as $slug => $meta)
|
||||||
|
<a href="{{ route('discover.' . $slug) }}"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
|
||||||
|
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
|
||||||
|
{{ $meta['label'] }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
||||||
|
@php
|
||||||
|
$galleryArtworks = $artworks->map(fn ($art) => [
|
||||||
|
'id' => $art->id,
|
||||||
|
'name' => $art->name ?? null,
|
||||||
|
'thumb' => $art->thumb_url ?? null,
|
||||||
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
|
'uname' => $art->uname ?? '',
|
||||||
|
'username' => $art->uname ?? '',
|
||||||
|
'category_name' => $art->category_name ?? '',
|
||||||
|
'category_slug' => $art->category_slug ?? '',
|
||||||
|
'slug' => $art->slug ?? '',
|
||||||
|
'width' => $art->width ?? null,
|
||||||
|
'height' => $art->height ?? null,
|
||||||
|
])->values();
|
||||||
|
@endphp
|
||||||
|
<div
|
||||||
|
data-react-masonry-gallery
|
||||||
|
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||||
|
data-gallery-type="for-you"
|
||||||
|
data-cursor-endpoint="{{ route('discover.for-you') }}"
|
||||||
|
@if (!empty($next_cursor)) data-next-cursor="{{ $next_cursor }}" @endif
|
||||||
|
data-limit="40"
|
||||||
|
class="min-h-32"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 8px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
@media (min-width: 2600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||||
|
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||||
|
.nova-skeleton-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
min-height: 180px;
|
||||||
|
background: linear-gradient(110deg, rgba(255,255,255,.06) 8%, rgba(255,255,255,.12) 18%, rgba(255,255,255,.06) 33%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: novaShimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes novaShimmer {
|
||||||
|
to { background-position-x: -200%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||||
|
@endpush
|
||||||
@@ -18,6 +18,13 @@
|
|||||||
|
|
||||||
{{-- Section switcher pills --}}
|
{{-- Section switcher pills --}}
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
@auth
|
||||||
|
<a href="{{ route('discover.for-you') }}"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-xs text-yellow-400/80"></i>
|
||||||
|
For You
|
||||||
|
</a>
|
||||||
|
@endauth
|
||||||
@php
|
@php
|
||||||
$sections = [
|
$sections = [
|
||||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||||
@@ -40,34 +47,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ── Artwork grid ── --}}
|
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
||||||
<div class="px-6 pb-16 md:px-10">
|
@php
|
||||||
@if ($artworks && $artworks->isNotEmpty())
|
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
'id' => $art->id,
|
||||||
@foreach ($artworks as $art)
|
'name' => $art->name ?? null,
|
||||||
@php
|
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||||
$card = (object)[
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
'id' => $art->id,
|
'uname' => $art->uname ?? '',
|
||||||
'name' => $art->name,
|
'username' => $art->uname ?? '',
|
||||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
'category_name' => $art->category_name ?? '',
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
'category_slug' => $art->category_slug ?? '',
|
||||||
'uname' => $art->uname ?? '',
|
'slug' => $art->slug ?? '',
|
||||||
'category_name' => $art->category_name ?? '',
|
'width' => $art->width ?? null,
|
||||||
];
|
'height' => $art->height ?? null,
|
||||||
@endphp
|
])->values();
|
||||||
<x-artwork-card :art="$card" />
|
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||||
@endforeach
|
@endphp
|
||||||
</div>
|
<div
|
||||||
|
data-react-masonry-gallery
|
||||||
{{-- Pagination --}}
|
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||||
<div class="mt-10 flex justify-center">
|
data-gallery-type="{{ $section ?? 'discover' }}"
|
||||||
{{ $artworks->withQueryString()->links() }}
|
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||||
</div>
|
data-limit="24"
|
||||||
@else
|
class="min-h-32"
|
||||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
></div>
|
||||||
<p class="text-white/40 text-sm">No artworks found for this section yet.</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 8px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
@media (min-width: 2600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
|
||||||
|
}
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-pagination] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-pagination] ul {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-pagination] li a,
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-pagination] li span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
color: #e6eef8;
|
||||||
|
border: 1px solid rgba(255,255,255,0.04);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||||
|
.nova-skeleton-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
min-height: 180px;
|
||||||
|
background: linear-gradient(110deg, rgba(255,255,255,.06) 8%, rgba(255,255,255,.12) 18%, rgba(255,255,255,.06) 33%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: novaShimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes novaShimmer {
|
||||||
|
to { background-position-x: -200%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||||
|
@endpush
|
||||||
|
|||||||
@@ -292,6 +292,20 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])->group
|
|||||||
->name('api.comments.reactions.toggle');
|
->name('api.comments.reactions.toggle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Personalised suggestions (auth required) ────────────────────────────────
|
||||||
|
// GET /api/user/suggestions/creators → up to 12 suggested creators to follow
|
||||||
|
// GET /api/user/suggestions/tags → up to 20 suggested tags (foundation)
|
||||||
|
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:30,1'])
|
||||||
|
->prefix('user/suggestions')
|
||||||
|
->name('api.user.suggestions.')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('creators', \App\Http\Controllers\Api\SuggestedCreatorsController::class)
|
||||||
|
->name('creators');
|
||||||
|
|
||||||
|
Route::get('tags', \App\Http\Controllers\Api\SuggestedTagsController::class)
|
||||||
|
->name('tags');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Follow system ─────────────────────────────────────────────────────────────
|
// ── Follow system ─────────────────────────────────────────────────────────────
|
||||||
// POST /api/user/{username}/follow → follow a user
|
// POST /api/user/{username}/follow → follow a user
|
||||||
// DELETE /api/user/{username}/follow → unfollow a user
|
// DELETE /api/user/{username}/follow → unfollow a user
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ Route::prefix('discover')->name('discover.')->group(function () {
|
|||||||
|
|
||||||
// Artworks from people you follow (auth required)
|
// Artworks from people you follow (auth required)
|
||||||
Route::middleware('auth')->get('/following', [DiscoverController::class, 'following'])->name('following');
|
Route::middleware('auth')->get('/following', [DiscoverController::class, 'following'])->name('following');
|
||||||
|
|
||||||
|
// Personalised "For You" feed (auth required; guests → redirect)
|
||||||
|
Route::middleware('auth')->get('/for-you', [DiscoverController::class, 'forYou'])->name('for-you');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── CREATORS routes (/creators/*) ─────────────────────────────────────────────
|
// ── CREATORS routes (/creators/*) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserRecommendationCache;
|
||||||
|
use App\Services\HomepageService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['scout.driver' => 'null']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allForUser includes a for_you key', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$service = app(HomepageService::class);
|
||||||
|
$result = $service->allForUser($user);
|
||||||
|
|
||||||
|
expect($result)->toHaveKey('for_you')
|
||||||
|
->and($result['for_you'])->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allForUser for_you is an array even with no cached recommendations', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$service = app(HomepageService::class);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$result = $service->allForUser($user);
|
||||||
|
|
||||||
|
expect($result['for_you'])->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allForUser for_you returns items when cache exists', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$artwork = Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'published_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
UserRecommendationCache::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'),
|
||||||
|
'cache_version' => (string) config('discovery.cache_version', 'cache-v1'),
|
||||||
|
'recommendations_json' => [
|
||||||
|
'items' => [
|
||||||
|
['artwork_id' => $artwork->id, 'score' => 0.9, 'source' => 'profile'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'generated_at' => now(),
|
||||||
|
'expires_at' => now()->addMinutes(60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$service = app(HomepageService::class);
|
||||||
|
$result = $service->allForUser($user);
|
||||||
|
|
||||||
|
expect($result['for_you'])->toBeArray();
|
||||||
|
// At least one item should have the base shape (id, title, slug, url)
|
||||||
|
if (count($result['for_you']) > 0) {
|
||||||
|
expect($result['for_you'][0])->toHaveKeys(['id', 'title', 'slug', 'url']);
|
||||||
|
}
|
||||||
|
});
|
||||||
176
tests/Feature/Recommendations/RecommendationEndpointsTest.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFollower;
|
||||||
|
use App\Models\UserRecommendationCache;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Disable Meilisearch so tests remain fast
|
||||||
|
config(['scout.driver' => 'null']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// /discover/for-you
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('redirects guests away from /discover/for-you', function () {
|
||||||
|
$this->get('/discover/for-you')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders For You page for authenticated user', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/discover/for-you')
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('For You page shows empty state with no prior activity', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/discover/for-you')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('For You');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('For You page uses cached recommendations when available', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$artwork = Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'published_at' => now()->subMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
UserRecommendationCache::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'),
|
||||||
|
'cache_version' => (string) config('discovery.cache_version', 'cache-v1'),
|
||||||
|
'recommendations_json' => [
|
||||||
|
'items' => [
|
||||||
|
['artwork_id' => $artwork->id, 'score' => 0.9, 'source' => 'profile'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'generated_at' => now(),
|
||||||
|
'expires_at' => now()->addMinutes(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/discover/for-you')
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// /api/user/suggestions/creators
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('requires auth for suggested creators endpoint', function () {
|
||||||
|
$this->getJson('/api/user/suggestions/creators')
|
||||||
|
->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns data array from suggested creators endpoint', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson('/api/user/suggestions/creators')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['data']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggested creators does not include the requesting user', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->getJson('/api/user/suggestions/creators')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$ids = collect($response->json('data'))->pluck('id')->all();
|
||||||
|
expect($ids)->not->toContain($user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggested creators excludes already-followed creators', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$followed = User::factory()->create();
|
||||||
|
|
||||||
|
UserFollower::create([
|
||||||
|
'user_id' => $followed->id,
|
||||||
|
'follower_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->getJson('/api/user/suggestions/creators')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$ids = collect($response->json('data'))->pluck('id')->all();
|
||||||
|
expect($ids)->not->toContain($followed->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// /api/user/suggestions/tags
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('requires auth for suggested tags endpoint', function () {
|
||||||
|
$this->getJson('/api/user/suggestions/tags')
|
||||||
|
->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns data array from suggested tags endpoint', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson('/api/user/suggestions/tags')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['data']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggested tags returns correct shape', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->getJson('/api/user/suggestions/tags')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$data = $response->json('data');
|
||||||
|
expect($data)->toBeArray();
|
||||||
|
|
||||||
|
// If non-empty each item must have these keys
|
||||||
|
foreach ($data as $item) {
|
||||||
|
expect($item)->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'source']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Similar artworks cache TTL
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('similar artworks endpoint returns 200 for a valid public artwork', function () {
|
||||||
|
$artwork = Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'published_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson("/api/art/{$artwork->id}/similar")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['data']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('similar artworks response is cached (second call hits cache layer)', function () {
|
||||||
|
$artwork = Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'published_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Two consecutive calls – the second must also succeed (confirming cache does not corrupt)
|
||||||
|
$this->getJson("/api/art/{$artwork->id}/similar")->assertOk();
|
||||||
|
$this->getJson("/api/art/{$artwork->id}/similar")->assertOk();
|
||||||
|
});
|
||||||
227
tests/Feature/Recommendations/RecommendationServiceTest.php
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\DTOs\UserRecoProfileDTO;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Recommendation\RecommendationService;
|
||||||
|
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Disable Meilisearch so tests remain fast / deterministic
|
||||||
|
config(['scout.driver' => 'null']);
|
||||||
|
|
||||||
|
// Seed recommendations config
|
||||||
|
config([
|
||||||
|
'recommendations.weights.tag_overlap' => 0.40,
|
||||||
|
'recommendations.weights.creator_affinity' => 0.25,
|
||||||
|
'recommendations.weights.popularity' => 0.20,
|
||||||
|
'recommendations.weights.freshness' => 0.15,
|
||||||
|
'recommendations.candidate_pool_size' => 200,
|
||||||
|
'recommendations.max_per_creator' => 3,
|
||||||
|
'recommendations.min_unique_tags' => 5,
|
||||||
|
'recommendations.ttl.for_you_feed' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// RecommendationService cold-start (no signals)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns cold-start feed when user has no signals', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Profile builder will return a DTO with no signals
|
||||||
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
||||||
|
$builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: [],
|
||||||
|
topCategorySlugs: [],
|
||||||
|
strongCreatorIds: [],
|
||||||
|
tagWeights: [],
|
||||||
|
categoryWeights: [],
|
||||||
|
dislikedTagSlugs: [],
|
||||||
|
));
|
||||||
|
|
||||||
|
$service = new RecommendationService($builder);
|
||||||
|
$result = $service->forYouFeed($user, 10);
|
||||||
|
|
||||||
|
expect($result)->toHaveKeys(['data', 'meta'])
|
||||||
|
->and($result['meta']['source'])->toBe('cold_start');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// RecommendationService personalised flow (mocked profile)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns personalised feed with data when user has signals', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Two artworks from other creators (tags not needed — Scout driver is null)
|
||||||
|
Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'user_id' => User::factory()->create()->id,
|
||||||
|
'published_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'user_id' => User::factory()->create()->id,
|
||||||
|
'published_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile = new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: ['cyberpunk', 'neon'],
|
||||||
|
topCategorySlugs: [],
|
||||||
|
strongCreatorIds: [],
|
||||||
|
tagWeights: ['cyberpunk' => 5.0, 'neon' => 3.0],
|
||||||
|
categoryWeights: [],
|
||||||
|
dislikedTagSlugs: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
||||||
|
$builder->shouldReceive('build')->with($user)->andReturn($profile);
|
||||||
|
|
||||||
|
$service = new RecommendationService($builder);
|
||||||
|
$result = $service->forYouFeed($user, 10);
|
||||||
|
|
||||||
|
expect($result)->toHaveKeys(['data', 'meta'])
|
||||||
|
->and($result['meta'])->toHaveKey('source');
|
||||||
|
// With scout null driver the collection is empty → cold-start path
|
||||||
|
// This tests the structure contract regardless of driver
|
||||||
|
expect($result['data'])->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Diversity: max 3 per creator
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('enforces max_per_creator diversity limit via forYouPreview', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$creatorA = User::factory()->create();
|
||||||
|
$creatorB = User::factory()->create();
|
||||||
|
|
||||||
|
// 4 artworks by creatorA, 1 by creatorB (Scout driver null — no Meili calls)
|
||||||
|
Artwork::factory(4)->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'user_id' => $creatorA->id,
|
||||||
|
'published_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
Artwork::factory()->create([
|
||||||
|
'is_public' => true,
|
||||||
|
'is_approved' => true,
|
||||||
|
'user_id' => $creatorB->id,
|
||||||
|
'published_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile = new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: ['abstract'],
|
||||||
|
topCategorySlugs: [],
|
||||||
|
strongCreatorIds: [],
|
||||||
|
tagWeights: ['abstract' => 5.0],
|
||||||
|
categoryWeights: [],
|
||||||
|
dislikedTagSlugs: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
||||||
|
$builder->shouldReceive('build')->andReturn($profile);
|
||||||
|
|
||||||
|
$service = new RecommendationService($builder);
|
||||||
|
|
||||||
|
// With null scout driver the candidate collection is empty; we test contract.
|
||||||
|
$result = $service->forYouFeed($user, 10);
|
||||||
|
expect($result)->toHaveKeys(['data', 'meta']);
|
||||||
|
expect($result['data'])->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Favourited artworks are excluded from For You feed
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('excludes artworks already favourited by user', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$art = Artwork::factory()->create(['is_public' => true, 'is_approved' => true]);
|
||||||
|
|
||||||
|
// Insert a favourite
|
||||||
|
DB::table('artwork_favourites')->insert([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'artwork_id' => $art->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile = new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: ['tag-x'],
|
||||||
|
topCategorySlugs: [],
|
||||||
|
strongCreatorIds: [],
|
||||||
|
tagWeights: ['tag-x' => 3.0],
|
||||||
|
categoryWeights: [],
|
||||||
|
dislikedTagSlugs: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
||||||
|
$builder->shouldReceive('build')->andReturn($profile);
|
||||||
|
|
||||||
|
$service = new RecommendationService($builder);
|
||||||
|
|
||||||
|
// With null scout, no candidates surface — checking that getFavoritedIds runs without error
|
||||||
|
$result = $service->forYouFeed($user, 10);
|
||||||
|
expect($result)->toHaveKeys(['data', 'meta']);
|
||||||
|
|
||||||
|
$artworkIds = array_column($result['data'], 'id');
|
||||||
|
expect($artworkIds)->not->toContain($art->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Cursor pagination shape
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns null next_cursor when no more pages available', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
||||||
|
$builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: [],
|
||||||
|
topCategorySlugs: [],
|
||||||
|
strongCreatorIds: [],
|
||||||
|
tagWeights: [],
|
||||||
|
categoryWeights: [],
|
||||||
|
dislikedTagSlugs: [],
|
||||||
|
));
|
||||||
|
|
||||||
|
$service = new RecommendationService($builder);
|
||||||
|
$result = $service->forYouFeed($user, 40, null);
|
||||||
|
|
||||||
|
expect($result['meta'])->toHaveKey('next_cursor');
|
||||||
|
// Cold-start with 0 results: next_cursor should be null
|
||||||
|
expect($result['meta']['next_cursor'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// forYouPreview is a subset of forYouFeed
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('forYouPreview returns an array', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
||||||
|
$builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: [], topCategorySlugs: [], strongCreatorIds: [],
|
||||||
|
tagWeights: [], categoryWeights: [], dislikedTagSlugs: [],
|
||||||
|
));
|
||||||
|
|
||||||
|
$service = new RecommendationService($builder);
|
||||||
|
$preview = $service->forYouPreview($user, 12);
|
||||||
|
|
||||||
|
expect($preview)->toBeArray();
|
||||||
|
});
|
||||||
81
tests/Feature/Recommendations/UserPreferenceBuilderTest.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\DTOs\UserRecoProfileDTO;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserRecoProfile;
|
||||||
|
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// UserRecoProfileDTO
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('DTO serialises and round-trips correctly', function () {
|
||||||
|
$dto = new UserRecoProfileDTO(
|
||||||
|
topTagSlugs: ['space', 'nature'],
|
||||||
|
topCategorySlugs: ['wallpapers'],
|
||||||
|
strongCreatorIds: [1, 2, 3],
|
||||||
|
tagWeights: ['space' => 0.6, 'nature' => 0.4],
|
||||||
|
categoryWeights: ['wallpapers' => 1.0],
|
||||||
|
);
|
||||||
|
|
||||||
|
$arr = $dto->toArray();
|
||||||
|
$restored = UserRecoProfileDTO::fromArray($arr);
|
||||||
|
|
||||||
|
expect($restored->topTagSlugs)->toBe(['space', 'nature'])
|
||||||
|
->and($restored->topCategorySlugs)->toBe(['wallpapers'])
|
||||||
|
->and($restored->strongCreatorIds)->toBe([1, 2, 3])
|
||||||
|
->and($restored->tagWeight('space'))->toBe(0.6)
|
||||||
|
->and($restored->followsCreator(2))->toBeTrue()
|
||||||
|
->and($restored->followsCreator(99))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DTO hasSignals returns false for empty profile', function () {
|
||||||
|
$empty = new UserRecoProfileDTO();
|
||||||
|
expect($empty->hasSignals())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DTO hasSignals returns true when tags are present', function () {
|
||||||
|
$dto = new UserRecoProfileDTO(topTagSlugs: ['space']);
|
||||||
|
expect($dto->hasSignals())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// UserPreferenceBuilder
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('UserPreferenceBuilder returns empty DTO for user with no activity', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$builder = app(UserPreferenceBuilder::class);
|
||||||
|
|
||||||
|
$dto = $builder->build($user);
|
||||||
|
|
||||||
|
expect($dto)->toBeInstanceOf(UserRecoProfileDTO::class)
|
||||||
|
->and($dto->topTagSlugs)->toBe([])
|
||||||
|
->and($dto->strongCreatorIds)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UserPreferenceBuilder persists profile row on first build', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$builder = app(UserPreferenceBuilder::class);
|
||||||
|
|
||||||
|
$builder->buildFresh($user);
|
||||||
|
|
||||||
|
expect(UserRecoProfile::find($user->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UserPreferenceBuilder produces stable output on repeated calls', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$builder = app(UserPreferenceBuilder::class);
|
||||||
|
|
||||||
|
$first = $builder->buildFresh($user)->toArray();
|
||||||
|
$second = $builder->buildFresh($user)->toArray();
|
||||||
|
|
||||||
|
expect($first)->toBe($second);
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
'resources/js/nova.js',
|
'resources/js/nova.js',
|
||||||
'resources/js/entry-topbar.jsx',
|
'resources/js/entry-topbar.jsx',
|
||||||
'resources/js/entry-search.jsx',
|
'resources/js/entry-search.jsx',
|
||||||
|
'resources/js/entry-masonry-gallery.jsx',
|
||||||
'resources/js/upload.jsx',
|
'resources/js/upload.jsx',
|
||||||
'resources/js/Pages/ArtworkPage.jsx',
|
'resources/js/Pages/ArtworkPage.jsx',
|
||||||
'resources/js/Pages/Home/HomePage.jsx',
|
'resources/js/Pages/Home/HomePage.jsx',
|
||||||
|
|||||||