diff --git a/app/Console/Commands/ConfigureMeilisearchIndex.php b/app/Console/Commands/ConfigureMeilisearchIndex.php new file mode 100644 index 00000000..3b1efdfb --- /dev/null +++ b/app/Console/Commands/ConfigureMeilisearchIndex.php @@ -0,0 +1,79 @@ +options(['sort' => …]). + * Must match keys in Artwork::toSearchableArray(). + */ + private const SORTABLE_ATTRIBUTES = [ + 'created_at', + 'trending_score_24h', + 'trending_score_7d', + 'favorites_count', + 'downloads_count', + 'awards_received_count', + 'views', + 'likes', + 'downloads', + ]; + + /** + * Fields used in filter expressions (AND category = "…" etc.). + */ + private const FILTERABLE_ATTRIBUTES = [ + 'is_public', + 'is_approved', + 'category', + 'content_type', + 'tags', + 'author_id', + 'orientation', + 'resolution', + ]; + + public function handle(): int + { + $indexName = (string) $this->option('index'); + + /** @var MeilisearchClient $client */ + $client = app(MeilisearchClient::class); + + $index = $client->index($indexName); + + $this->info("Configuring Meilisearch index: {$indexName}"); + + // ── Sortable attributes ─────────────────────────────────────────────── + $this->line(' → Updating sortableAttributes…'); + $task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES); + $this->line(" Task uid: {$task['taskUid']}"); + + // ── Filterable attributes ───────────────────────────────────────────── + $this->line(' → Updating filterableAttributes…'); + $task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES); + $this->line(" Task uid: {$task2['taskUid']}"); + + $this->info('Done. Meilisearch will process these tasks asynchronously.'); + $this->warn('Re-index artworks if sortable attributes changed: php artisan artworks:search-rebuild'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateWallzStatsCommand.php b/app/Console/Commands/MigrateWallzStatsCommand.php new file mode 100644 index 00000000..615961cb --- /dev/null +++ b/app/Console/Commands/MigrateWallzStatsCommand.php @@ -0,0 +1,123 @@ +option('chunk'); + $dryRun = (bool) $this->option('dry-run'); + + if ($dryRun) { + $this->warn('[DRY RUN] No data will be written.'); + } + + $total = (int) DB::connection('legacy')->table('wallz')->count(); + $processed = 0; + $inserted = 0; + $updated = 0; + + $this->info("Found {$total} rows in legacy wallz table. Chunk size: {$chunkSize}."); + + $bar = $this->output->createProgressBar($total); + $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% — ins: %message%'); + $bar->setMessage('0 ins / 0 upd'); + $bar->start(); + + DB::connection('legacy') + ->table('wallz') + ->select('id', 'views', 'dls', 'rating', 'rating_num') + ->orderBy('id') + ->chunk($chunkSize, function ($rows) use ($dryRun, &$processed, &$inserted, &$updated, $bar) { + $artworkIds = $rows->pluck('id')->all(); + + // Find which artwork_ids already have a stats row. + $existing = DB::table('artwork_stats') + ->whereIn('artwork_id', $artworkIds) + ->pluck('artwork_id') + ->flip(); // flip → [artwork_id => index] for O(1) lookup + + $toInsert = []; + $now = now()->toDateTimeString(); + + foreach ($rows as $row) { + $views = max(0, (int) $row->views); + $dls = max(0, (int) $row->dls); + $ratingAvg = max(0, (float) $row->rating); + $ratingCount = max(0, (int) $row->rating_num); + + if ($existing->has($row->id)) { + // Update existing row. + if (! $dryRun) { + DB::table('artwork_stats') + ->where('artwork_id', $row->id) + ->update([ + 'views' => $views, + 'downloads' => $dls, + 'rating_avg' => $ratingAvg, + 'rating_count' => $ratingCount, + ]); + } + $updated++; + } else { + // Batch-collect for insert. + $toInsert[] = [ + 'artwork_id' => $row->id, + 'views' => $views, + 'views_24h' => 0, + 'views_7d' => 0, + 'downloads' => $dls, + 'downloads_24h' => 0, + 'downloads_7d' => 0, + 'favorites' => 0, + 'rating_avg' => $ratingAvg, + 'rating_count' => $ratingCount, + ]; + $inserted++; + } + } + + if (! $dryRun && ! empty($toInsert)) { + DB::table('artwork_stats')->insertOrIgnore($toInsert); + } + + $processed += count($rows); + $bar->setMessage("{$inserted} ins / {$updated} upd"); + $bar->advance(count($rows)); + }); + + $bar->finish(); + $this->newLine(); + + if ($dryRun) { + $this->warn("DRY RUN complete — would insert {$inserted}, update {$updated} ({$processed} rows scanned)."); + } else { + $this->info("Done — inserted {$inserted}, updated {$updated} ({$processed} rows processed)."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index dddcba35..9a7026b9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,6 +13,8 @@ use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\RecalculateTrendingCommand; +use App\Jobs\RankComputeArtworkScoresJob; +use App\Jobs\RankBuildListsJob; use App\Uploads\Commands\CleanupUploadsCommand; class Kernel extends ConsoleKernel @@ -51,6 +53,12 @@ class Kernel extends ConsoleKernel // Recalculate trending scores every 30 minutes (staggered to reduce peak load) $schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes(); $schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground(); + + // ── Ranking system (rank_v1) ──────────────────────────────────────── + // Step 1: compute per-artwork scores every hour at :05 + $schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground(); + // Step 2: build ranked lists every hour at :15 (after scores are ready) + $schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground(); } /** diff --git a/app/Http/Controllers/Api/RankController.php b/app/Http/Controllers/Api/RankController.php new file mode 100644 index 00000000..6b84626f --- /dev/null +++ b/app/Http/Controllers/Api/RankController.php @@ -0,0 +1,144 @@ +resolveListType($request); + $result = $this->ranking->getList('global', null, $listType); + + return $this->buildResponse($result, $listType); + } + + /** + * GET /api/rank/category/{id} + */ + public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse + { + if (! Category::where('id', $id)->where('is_active', true)->exists()) { + return response()->json(['message' => 'Category not found.'], 404); + } + + $listType = $this->resolveListType($request); + $result = $this->ranking->getList('category', $id, $listType); + + return $this->buildResponse($result, $listType); + } + + /** + * GET /api/rank/type/{contentType} + * + * {contentType} is accepted as either a slug (string) or numeric id. + */ + public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse + { + $ct = is_numeric($contentType) + ? ContentType::find((int) $contentType) + : ContentType::where('slug', $contentType)->first(); + + if ($ct === null) { + return response()->json(['message' => 'Content type not found.'], 404); + } + + $listType = $this->resolveListType($request); + $result = $this->ranking->getList('content_type', $ct->id, $listType); + + return $this->buildResponse($result, $listType); + } + + // ── Private helpers ──────────────────────────────────────────────────── + + /** + * Validate and normalise the ?type query param. + * Defaults to 'trending'. + */ + private function resolveListType(Request $request): string + { + $allowed = ['trending', 'new_hot', 'best']; + $type = $request->query('type', 'trending'); + + return in_array($type, $allowed, true) ? $type : 'trending'; + } + + /** + * Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources. + * + * @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result + */ + private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection + { + $ids = $result['ids']; + $artworks = collect(); + + if (! empty($ids)) { + // Single whereIn query — no N+1 + $keyed = Artwork::whereIn('id', $ids) + ->with([ + 'user:id,name', + 'categories' => function ($q): void { + $q->select( + 'categories.id', + 'categories.content_type_id', + 'categories.parent_id', + 'categories.name', + 'categories.slug', + 'categories.sort_order' + )->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); + }, + ]) + ->get() + ->keyBy('id'); + + // Restore the ranked order + $artworks = collect($ids) + ->filter(fn ($id) => $keyed->has($id)) + ->map(fn ($id) => $keyed[$id]); + } + + $collection = ArtworkListResource::collection($artworks); + + // Attach ranking meta as additional data + $collection->additional([ + 'meta' => [ + 'list_type' => $listType, + 'computed_at' => $result['computed_at'], + 'model_version' => $result['model_version'], + 'fallback' => $result['fallback'], + 'count' => $artworks->count(), + ], + ]); + + return $collection; + } +} diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index cdeb4d64..73652873 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -5,10 +5,12 @@ namespace App\Http\Controllers\Web; use App\Models\Category; use App\Models\ContentType; use App\Models\Artwork; -use App\Services\ArtworkService; use App\Services\ArtworkSearchService; +use App\Services\ArtworkService; +use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Pagination\AbstractPaginator; use Illuminate\Pagination\AbstractCursorPaginator; @@ -16,11 +18,56 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller { private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other']; + /** + * Meilisearch sort-field arrays per sort alias. + * First element is primary sort; subsequent elements are tie-breakers. + */ private const SORT_MAP = [ - 'latest' => 'created_at:desc', - 'popular' => 'views:desc', - 'liked' => 'likes:desc', - 'downloads' => 'downloads:desc', + // ── Nova sort aliases ───────────────────────────────────────────────── + // trending_score_24h only covers artworks ≤ 7 days old; use 7d score + // and favorites_count as fallbacks so older artworks don't all tie at 0. + 'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], + // "New & Hot": 30-day trending window surfaces recently-active artworks. + 'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], + 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], + 'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'], + 'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'], + 'oldest' => ['created_at:asc'], + // ── Legacy aliases (backward compat) ────────────────────────────────── + 'latest' => ['created_at:desc'], + 'popular' => ['views:desc', 'favorites_count:desc'], + 'liked' => ['likes:desc', 'favorites_count:desc'], + 'downloads' => ['downloads:desc', 'downloads_count:desc'], + ]; + + /** + * Cache TTL (seconds) per sort alias. + * trending → 5 min + * fresh → 2 min + * top-rated → 10 min + * others → 5 min + */ + private const SORT_TTL_MAP = [ + 'trending' => 300, + 'fresh' => 120, + 'top-rated' => 600, + 'favorited' => 300, + 'downloaded' => 300, + 'oldest' => 600, + 'latest' => 120, + 'popular' => 300, + 'liked' => 300, + 'downloads' => 300, + ]; + + /** Human-readable sort options passed to every gallery view. */ + private const SORT_OPTIONS = [ + ['value' => 'trending', 'label' => '🔥 Trending'], + ['value' => 'fresh', 'label' => '🆕 Fresh'], + ['value' => 'top-rated', 'label' => '⭐ Top Rated'], + ['value' => 'favorited', 'label' => '❤️ Most Favorited'], + ['value' => 'downloaded', 'label' => '⬇ Most Downloaded'], + ['value' => 'oldest', 'label' => '📅 Oldest'], ]; public function __construct( @@ -31,34 +78,43 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller public function browse(Request $request) { - $sort = (string) $request->query('sort', 'latest'); + $sort = $this->resolveSort($request, 'trending'); $perPage = $this->resolvePerPage($request); + $page = (int) $request->query('page', 1); + $ttl = self::SORT_TTL_MAP[$sort] ?? 300; - $artworks = Artwork::search('')->options([ - 'filter' => 'is_public = true AND is_approved = true', - 'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'], - ])->paginate($perPage); + $artworks = Cache::remember( + "browse.all.{$sort}.{$page}", + $ttl, + fn () => Artwork::search('')->options([ + 'filter' => 'is_public = true AND is_approved = true', + 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], + ])->paginate($perPage) + ); + $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/browse'), $artworks); $mainCategories = $this->mainCategories(); return view('gallery.index', [ - 'gallery_type' => 'browse', - 'mainCategories' => $mainCategories, - 'subcategories' => $mainCategories, - 'contentType' => null, - 'category' => null, - 'artworks' => $artworks, - 'hero_title' => 'Browse Artworks', + 'gallery_type' => 'browse', + 'mainCategories' => $mainCategories, + 'subcategories' => $mainCategories, + 'contentType' => null, + 'category' => null, + 'artworks' => $artworks, + 'current_sort' => $sort, + 'sort_options' => self::SORT_OPTIONS, + 'hero_title' => 'Browse Artworks', 'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.', - 'breadcrumbs' => collect(), - 'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase', + 'breadcrumbs' => collect(), + 'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase', 'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.", - 'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo', - 'page_canonical' => $seo['canonical'], - 'page_rel_prev' => $seo['prev'], - 'page_rel_next' => $seo['next'], - 'page_robots' => 'index,follow', + 'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo', + 'page_canonical' => $seo['canonical'], + 'page_rel_prev' => $seo['prev'], + 'page_rel_next' => $seo['next'], + 'page_robots' => 'index,follow', ]); } @@ -74,37 +130,47 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller abort(404); } - $sort = (string) $request->query('sort', 'latest'); + // Default sort: trending (not chronological) + $sort = $this->resolveSort($request, 'trending'); $perPage = $this->resolvePerPage($request); + $page = (int) $request->query('page', 1); + $ttl = self::SORT_TTL_MAP[$sort] ?? 300; $mainCategories = $this->mainCategories(); $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); $normalizedPath = trim((string) $path, '/'); if ($normalizedPath === '') { - $artworks = Artwork::search('')->options([ - 'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"', - 'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'], - ])->paginate($perPage); + $artworks = Cache::remember( + "gallery.ct.{$contentSlug}.{$sort}.{$page}", + $ttl, + fn () => Artwork::search('')->options([ + 'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"', + 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], + ])->paginate($perPage) + ); + $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks); return view('gallery.index', [ - 'gallery_type' => 'content-type', - 'mainCategories' => $mainCategories, - 'subcategories' => $rootCategories, - 'contentType' => $contentType, - 'category' => null, - 'artworks' => $artworks, - 'hero_title' => $contentType->name, + 'gallery_type' => 'content-type', + 'mainCategories' => $mainCategories, + 'subcategories' => $rootCategories, + 'contentType' => $contentType, + 'category' => null, + 'artworks' => $artworks, + 'current_sort' => $sort, + 'sort_options' => self::SORT_OPTIONS, + 'hero_title' => $contentType->name, 'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'), - 'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]), - 'page_title' => $contentType->name, - 'page_meta_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase'), - 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', - 'page_canonical' => $seo['canonical'], - 'page_rel_prev' => $seo['prev'], - 'page_rel_next' => $seo['next'], - 'page_robots' => 'index,follow', + 'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]), + 'page_title' => $contentType->name . ' – Skinbase Nova', + 'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'), + 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', + 'page_canonical' => $seo['canonical'], + 'page_rel_prev' => $seo['prev'], + 'page_rel_next' => $seo['next'], + 'page_robots' => 'index,follow', ]); } @@ -114,10 +180,16 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller abort(404); } - $artworks = Artwork::search('')->options([ - 'filter' => 'is_public = true AND is_approved = true AND category = "' . $category->slug . '"', - 'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'], - ])->paginate($perPage); + $catSlug = $category->slug; + $artworks = Cache::remember( + "gallery.cat.{$catSlug}.{$sort}.{$page}", + $ttl, + fn () => Artwork::search('')->options([ + 'filter' => 'is_public = true AND is_approved = true AND category = "' . $catSlug . '"', + 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], + ])->paginate($perPage) + ); + $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks); $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); @@ -134,22 +206,24 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller }); return view('gallery.index', [ - 'gallery_type' => 'category', - 'mainCategories' => $mainCategories, - 'subcategories' => $subcategories, - 'contentType' => $contentType, - 'category' => $category, - 'artworks' => $artworks, - 'hero_title' => $category->name, + 'gallery_type' => 'category', + 'mainCategories' => $mainCategories, + 'subcategories' => $subcategories, + 'contentType' => $contentType, + 'category' => $category, + 'artworks' => $artworks, + 'current_sort' => $sort, + 'sort_options' => self::SORT_OPTIONS, + 'hero_title' => $category->name, 'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'), - 'breadcrumbs' => $breadcrumbs, - 'page_title' => $category->name, - 'page_meta_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase'), - 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', - 'page_canonical' => $seo['canonical'], - 'page_rel_prev' => $seo['prev'], - 'page_rel_next' => $seo['next'], - 'page_robots' => 'index,follow', + 'breadcrumbs' => $breadcrumbs, + 'page_title' => $category->name . ' – Skinbase Nova', + 'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'), + 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', + 'page_canonical' => $seo['canonical'], + 'page_rel_prev' => $seo['prev'], + 'page_rel_next' => $seo['next'], + 'page_robots' => 'index,follow', ]); } @@ -211,16 +285,53 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller return redirect($target, 301); } + private function presentArtwork(Artwork $artwork): object + { + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); + $present = ThumbnailPresenter::present($artwork, 'md'); + $avatarUrl = \App\Support\AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); + + return (object) [ + 'id' => $artwork->id, + 'name' => $artwork->title, + 'category_name' => $primaryCategory->name ?? '', + 'category_slug' => $primaryCategory->slug ?? '', + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $artwork->user?->name ?? 'Skinbase', + 'username' => $artwork->user?->username ?? '', + 'avatar_url' => $avatarUrl, + 'published_at' => $artwork->published_at, + 'width' => $artwork->width ?? null, + 'height' => $artwork->height ?? null, + ]; + } + private function resolvePerPage(Request $request): int { $limit = (int) $request->query('limit', 0); $perPage = (int) $request->query('per_page', 0); - $value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 40); + // Spec §8: recommended 24 per page on category/gallery pages + $value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24); return max(12, min($value, 80)); } + /** + * Validate and return the requested sort alias, falling back to $default. + * Only allows keys present in SORT_MAP. + */ + private function resolveSort(Request $request, string $default = 'trending'): string + { + $requested = (string) $request->query('sort', $default); + return array_key_exists($requested, self::SORT_MAP) ? $requested : $default; + } + private function mainCategories(): Collection { return ContentType::orderBy('id') diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 38a963a2..2d55365b 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -38,7 +38,7 @@ final class DiscoverController extends Controller { $perPage = 24; $results = $this->searchService->discoverTrending($perPage); - $artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ 'artworks' => $results, @@ -55,7 +55,7 @@ final class DiscoverController extends Controller { $perPage = 24; $results = $this->searchService->discoverFresh($perPage); - $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ 'artworks' => $results, @@ -72,7 +72,7 @@ final class DiscoverController extends Controller { $perPage = 24; $results = $this->searchService->discoverTopRated($perPage); - $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ 'artworks' => $results, @@ -89,7 +89,7 @@ final class DiscoverController extends Controller { $perPage = 24; $results = $this->searchService->discoverMostDownloaded($perPage); - $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + $this->hydrateDiscoverSearchResults($results); return view('web.discover.index', [ 'artworks' => $results, @@ -110,7 +110,11 @@ final class DiscoverController extends Controller $artworks = Artwork::query() ->public() ->published() - ->with(['user:id,name', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) + ->with([ + 'user:id,name', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + ]) ->whereRaw('MONTH(published_at) = ?', [$today->month]) ->whereRaw('DAY(published_at) = ?', [$today->day]) ->whereRaw('YEAR(published_at) < ?', [$today->year]) @@ -206,16 +210,27 @@ final class DiscoverController extends Controller $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'] ?? '', - ]); + $artworks = collect($artworkItems)->map(function (array $item) { + $width = isset($item['width']) && $item['width'] > 0 ? (int) $item['width'] : null; + $height = isset($item['height']) && $item['height'] > 0 ? (int) $item['height'] : null; + $avatarUrl = \App\Support\AvatarUrl::forUser((int) ($item['author_id'] ?? 0), null, 64); + + return (object) [ + 'id' => $item['id'] ?? 0, + 'name' => $item['title'] ?? 'Untitled', + 'category_name' => $item['category_name'] ?? '', + 'category_slug' => $item['category_slug'] ?? '', + 'thumb_url' => $item['thumbnail_url'] ?? null, + 'thumb_srcset' => $item['thumbnail_url'] ?? null, + 'uname' => $item['author'] ?? 'Artist', + 'username' => $item['username'] ?? '', + 'avatar_url' => $avatarUrl, + 'published_at' => $item['published_at'] ?? null, + 'slug' => $item['slug'] ?? '', + 'width' => $width, + 'height' => $height, + ]; + }); $meta = $feedResult['meta'] ?? []; $nextCursor = $meta['next_cursor'] ?? null; @@ -308,10 +323,73 @@ final class DiscoverController extends Controller // ─── Helpers ───────────────────────────────────────────────────────────── + private function hydrateDiscoverSearchResults($paginator): void + { + if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) { + return; + } + + $items = $paginator->getCollection(); + if (!$items || $items->isEmpty()) { + return; + } + + $ids = $items + ->pluck('id') + ->filter(fn ($id) => is_numeric($id) && (int) $id > 0) + ->map(fn ($id) => (int) $id) + ->values(); + + if ($ids->isEmpty()) { + return; + } + + $byId = Artwork::query() + ->whereIn('id', $ids) + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + ]) + ->get() + ->keyBy('id'); + + $paginator->setCollection( + $items->map(function ($item) use ($byId) { + $id = (int) ($item->id ?? 0); + $full = $id > 0 ? $byId->get($id) : null; + + if ($full instanceof Artwork) { + return $this->presentArtwork($full); + } + + return (object) [ + 'id' => $item->id ?? 0, + 'name' => $item->title ?? $item->name ?? 'Untitled', + 'category_name' => $item->category_name ?? $item->category ?? '', + 'category_slug' => $item->category_slug ?? '', + 'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null, + 'thumb_srcset' => $item->thumb_srcset ?? null, + 'uname' => $item->author ?? $item->uname ?? 'Skinbase', + 'username' => $item->username ?? '', + 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64), + 'published_at' => $item->published_at ?? null, + 'width' => isset($item->width) && $item->width ? (int) $item->width : null, + 'height' => isset($item->height) && $item->height ? (int) $item->height : null, + ]; + }) + ); + } + private function presentArtwork(Artwork $artwork): object { $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $present = ThumbnailPresenter::present($artwork, 'md'); + $avatarUrl = \App\Support\AvatarUrl::forUser( + (int) ($artwork->user_id ?? 0), + $artwork->user?->profile?->avatar_hash ?? null, + 64 + ); return (object) [ 'id' => $artwork->id, @@ -322,6 +400,7 @@ final class DiscoverController extends Controller 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $artwork->user->name ?? 'Skinbase', + 'avatar_url' => $avatarUrl, 'published_at' => $artwork->published_at, 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, diff --git a/app/Jobs/RankBuildListsJob.php b/app/Jobs/RankBuildListsJob.php new file mode 100644 index 00000000..9834eae0 --- /dev/null +++ b/app/Jobs/RankBuildListsJob.php @@ -0,0 +1,206 @@ +buildAndStore( + $ranking, $listType, 'global', 0, + $modelVersion, $maxPerAuthor, $listSize, $candidatePool + ); + $listsBuilt++; + } + + // ── 2. Per category ──────────────────────────────────────────────── + Category::query() + ->select(['id']) + ->where('is_active', true) + ->orderBy('id') + ->chunk(200, function ($categories) use ( + $ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt + ): void { + foreach ($categories as $cat) { + foreach (self::LIST_TYPES as $listType) { + $this->buildAndStore( + $ranking, $listType, 'category', $cat->id, + $modelVersion, $maxPerAuthor, $listSize, $candidatePool + ); + $listsBuilt++; + } + } + }); + + // ── 3. Per content type ──────────────────────────────────────────── + ContentType::query() + ->select(['id']) + ->orderBy('id') + ->chunk(50, function ($ctypes) use ( + $ranking, $modelVersion, $maxPerAuthor, $listSize, $candidatePool, &$listsBuilt + ): void { + foreach ($ctypes as $ct) { + foreach (self::LIST_TYPES as $listType) { + $this->buildAndStore( + $ranking, $listType, 'content_type', $ct->id, + $modelVersion, $maxPerAuthor, $listSize, $candidatePool + ); + $listsBuilt++; + } + } + }); + + Log::info('RankBuildListsJob: finished', [ + 'lists_built' => $listsBuilt, + 'model_version' => $modelVersion, + ]); + } + + // ── Private helpers ──────────────────────────────────────────────────── + + /** + * Fetch candidates, apply diversity, and upsert the resulting list. + */ + private function buildAndStore( + RankingService $ranking, + string $listType, + string $scopeType, + int $scopeId, + string $modelVersion, + int $maxPerAuthor, + int $listSize, + int $candidatePool + ): void { + $scoreCol = $this->scoreColumn($listType); + $candidates = $this->fetchCandidates( + $scopeType, $scopeId, $scoreCol, $candidatePool, $modelVersion + ); + + $diverse = $ranking->applyDiversity( + $candidates->all(), $maxPerAuthor, $listSize + ); + + $ids = array_map( + fn ($item) => (int) ($item->artwork_id ?? $item['artwork_id']), + $diverse + ); + + // Upsert the list (unique: scope_type + scope_id + list_type + model_version) + DB::table('rank_lists')->upsert( + [[ + 'scope_type' => $scopeType, + 'scope_id' => $scopeId, + 'list_type' => $listType, + 'model_version' => $modelVersion, + 'artwork_ids' => json_encode($ids), + 'computed_at' => now()->toDateTimeString(), + ]], + ['scope_type', 'scope_id', 'list_type', 'model_version'], + ['artwork_ids', 'computed_at'] + ); + + // Bust Redis cache so next request picks up the new list + $ranking->bustCache($scopeType, $scopeId === 0 ? null : $scopeId, $listType); + } + + /** + * Fetch top N candidates (with user_id) for a given scope/score column. + * + * @return \Illuminate\Support\Collection + */ + private function fetchCandidates( + string $scopeType, + int $scopeId, + string $scoreCol, + int $limit, + string $modelVersion + ): \Illuminate\Support\Collection { + $query = DB::table('rank_artwork_scores as ras') + ->select(['ras.artwork_id', 'a.user_id', "ras.{$scoreCol}"]) + ->join('artworks as a', function ($join): void { + $join->on('a.id', '=', 'ras.artwork_id') + ->where('a.is_public', 1) + ->where('a.is_approved', 1) + ->whereNull('a.deleted_at'); + }) + ->where('ras.model_version', $modelVersion) + ->orderByDesc("ras.{$scoreCol}") + ->limit($limit); + + if ($scopeType === 'category' && $scopeId > 0) { + $query->join( + 'artwork_category as ac', + fn ($j) => $j->on('ac.artwork_id', '=', 'a.id') + ->where('ac.category_id', $scopeId) + ); + } + + if ($scopeType === 'content_type' && $scopeId > 0) { + $query->join( + 'artwork_category as ac', + 'ac.artwork_id', '=', 'a.id' + )->join( + 'categories as cat', + fn ($j) => $j->on('cat.id', '=', 'ac.category_id') + ->where('cat.content_type_id', $scopeId) + ->whereNull('cat.deleted_at') + ); + } + + return $query->get(); + } + + /** + * Map list_type to the rank_artwork_scores column name. + */ + private function scoreColumn(string $listType): string + { + return match ($listType) { + 'new_hot' => 'score_new_hot', + 'best' => 'score_best', + default => 'score_trending', + }; + } +} diff --git a/app/Jobs/RankComputeArtworkScoresJob.php b/app/Jobs/RankComputeArtworkScoresJob.php new file mode 100644 index 00000000..97df1f32 --- /dev/null +++ b/app/Jobs/RankComputeArtworkScoresJob.php @@ -0,0 +1,79 @@ +toDateTimeString(); + + $ranking->artworkSignalsQuery() + ->orderBy('a.id') + ->chunk(self::CHUNK_SIZE, function ($rows) use ($ranking, $modelVersion, $now, &$total): void { + $rows = collect($rows); + + if ($rows->isEmpty()) { + return; + } + + $upserts = $rows->map(function ($row) use ($ranking, $modelVersion, $now): array { + $scores = $ranking->computeScores($row); + + return [ + 'artwork_id' => (int) $row->id, + 'score_trending' => $scores['score_trending'], + 'score_new_hot' => $scores['score_new_hot'], + 'score_best' => $scores['score_best'], + 'model_version' => $modelVersion, + 'computed_at' => $now, + ]; + })->all(); + + DB::table('rank_artwork_scores')->upsert( + $upserts, + ['artwork_id'], // unique key + ['score_trending', 'score_new_hot', 'score_best', // update these + 'model_version', 'computed_at'] + ); + + $total += count($upserts); + }); + + Log::info('RankComputeArtworkScoresJob: finished', [ + 'total_updated' => $total, + 'model_version' => $modelVersion, + ]); + } +} diff --git a/app/Models/RankArtworkScore.php b/app/Models/RankArtworkScore.php new file mode 100644 index 00000000..088a4840 --- /dev/null +++ b/app/Models/RankArtworkScore.php @@ -0,0 +1,69 @@ + 'integer', + 'score_trending' => 'float', + 'score_new_hot' => 'float', + 'score_best' => 'float', + 'computed_at' => 'datetime', + ]; + + // ── Relations ────────────────────────────────────────────────────────── + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'artwork_id'); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /** + * Map list_type string to the corresponding score column. + */ + public static function scoreColumn(string $listType): string + { + return match ($listType) { + 'new_hot' => 'score_new_hot', + 'best' => 'score_best', + default => 'score_trending', + }; + } +} diff --git a/app/Models/RankList.php b/app/Models/RankList.php new file mode 100644 index 00000000..b6bc2291 --- /dev/null +++ b/app/Models/RankList.php @@ -0,0 +1,53 @@ + 'integer', + 'artwork_ids' => 'array', + 'computed_at' => 'datetime', + ]; + + // ── Scope helpers ────────────────────────────────────────────────────── + + /** Resolve scope_id: null → 0 (global sentinel). */ + public static function resolveScope(?int $id): int + { + return $id ?? 0; + } +} diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index 78018b2b..7cc33f00 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -113,6 +113,80 @@ final class ArtworkSearchService }); } + // ── Category / Content-Type page sorts ──────────────────────────────────── + + /** + * Meilisearch sort fields per alias. + * Used by categoryPageSort() and contentTypePageSort(). + */ + private const CATEGORY_SORT_FIELDS = [ + 'trending' => ['trending_score_24h:desc', 'created_at:desc'], + 'fresh' => ['created_at:desc'], + 'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'], + 'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'], + 'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'], + 'oldest' => ['created_at:asc'], + ]; + + /** Cache TTL (seconds) per sort alias for category pages. */ + private const CATEGORY_SORT_TTL = [ + 'trending' => 300, // 5 min + 'fresh' => 120, // 2 min + 'top-rated' => 600, // 10 min + 'favorited' => 300, + 'downloaded' => 300, + 'oldest' => 600, + ]; + + /** + * Artworks for a single category page, sorted via Meilisearch. + * Default sort: trending (trending_score_24h:desc). + * + * Cache key pattern: category.{slug}.{sort}.{page} + * TTL varies by sort (see spec: 5/2/10 min). + */ + public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator + { + $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; + $page = (int) request()->get('page', 1); + $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; + $cacheKey = "category.{$categorySlug}.{$sort}.{$page}"; + + return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"', + 'sort' => self::CATEGORY_SORT_FIELDS[$sort], + ]) + ->paginate($perPage); + }); + } + + /** + * Artworks for a content-type root page, sorted via Meilisearch. + * Default sort: trending. + * + * Cache key pattern: content_type.{slug}.{sort}.{page} + */ + public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator + { + $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; + $page = (int) request()->get('page', 1); + $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; + $cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$page}"; + + return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"', + 'sort' => self::CATEGORY_SORT_FIELDS[$sort], + ]) + ->paginate($perPage); + }); + } + + // ------------------------------------------------------------------------- + /** * Related artworks: same tags, different artwork, ranked by views + likes. * Limit 12. diff --git a/app/Services/RankingService.php b/app/Services/RankingService.php new file mode 100644 index 00000000..1edc753c --- /dev/null +++ b/app/Services/RankingService.php @@ -0,0 +1,342 @@ +views_7d)) + + ($wF * log(1 + (float) $row->favourites_7d)) + + ($wD * log(1 + (float) $row->downloads_7d)); + + // Base engagement (all-time, for "best" score) + $E_all = ($wV * log(1 + (float) $row->views_all)) + + ($wF * log(1 + (float) $row->favourites_all)) + + ($wD * log(1 + (float) $row->downloads_all)); + + // 3.2 Freshness decay + $ageH = max(0.0, (float) $row->age_hours); + $decayTrending = exp(-$ageH / (float) $cfg['half_life']['trending']); + $decayNewHot = exp(-$ageH / (float) $cfg['half_life']['new_hot']); + $decayBest = exp(-$ageH / (float) $cfg['half_life']['best']); + + // 3.3 Quality modifier + $tagCount = (int) $row->tag_count; + $hasTags = $tagCount > 0; + $hasThumb = (bool) $row->has_thumbnail; + $isVisible = (bool) $row->is_public && (bool) $row->is_approved; + + $Q = 1.0; + if ($hasTags) { $Q += (float) $cfg['quality']['has_tags']; } + if ($hasThumb) { $Q += (float) $cfg['quality']['has_thumbnail']; } + $Q += (float) $cfg['quality']['tag_count_bonus'] + * (min($tagCount, (int) $cfg['quality']['tag_count_max']) + / (float) $cfg['quality']['tag_count_max']); + if (! $isVisible) { $Q -= (float) $cfg['quality']['penalty_hidden']; } + + // 3.4 Novelty boost (New & Hot) + $noveltyW = (float) $cfg['novelty_weight']; + $novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0); + + // Anti-spam damping on trending score only + $spamFactor = 1.0; + $spam = $cfg['spam']; + if ( + (float) $row->views_24h > (float) $spam['views_24h_threshold'] + && (float) $row->views_24h > 0 + ) { + $rF = (float) $row->favourites_24h / (float) $row->views_24h; + $rD = (float) $row->downloads_24h / (float) $row->views_24h; + if ($rF < (float) $spam['fav_ratio_threshold'] + && $rD < (float) $spam['dl_ratio_threshold'] + ) { + $spamFactor = (float) $spam['trending_penalty_factor']; + } + } + + $scoreTrending = $E * $decayTrending * (1.0 + $Q) * $spamFactor; + $scoreNewHot = $E * $decayNewHot * $novelty * (1.0 + $Q); + $scoreBest = $E_all * $decayBest * (1.0 + $Q); + + return [ + 'score_trending' => max(0.0, $scoreTrending), + 'score_new_hot' => max(0.0, $scoreNewHot), + 'score_best' => max(0.0, $scoreBest), + ]; + } + + // ── Diversity filtering ──────────────────────────────────────────────── + + /** + * Apply author-diversity cap to an already-ordered candidate array. + * + * @param array $candidates Ordered array, each element must have artwork_id + user_id. + * @param int $maxPerAuthor + * @param int $listSize + * @return array Filtered, at most $listSize elements. + */ + public function applyDiversity(array $candidates, int $maxPerAuthor, int $listSize): array + { + $result = []; + $authorCount = []; + + foreach ($candidates as $item) { + $uid = (int) ($item->user_id ?? $item['user_id'] ?? 0); + + if (($authorCount[$uid] ?? 0) >= $maxPerAuthor) { + continue; + } + + $result[] = $item; + $authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1; + + if (count($result) >= $listSize) { + break; + } + } + + return $result; + } + + // ── List retrieval ───────────────────────────────────────────────────── + + /** + * Retrieve a ranked list of artwork IDs. + * + * Order of precedence: + * 1. Redis cache + * 2. rank_lists table + * 3. Fallback: latest-first from artworks + * + * @param string $scopeType global | category | content_type + * @param int|null $scopeId category.id or content_type.id, null for global + * @param string $listType trending | new_hot | best + * @return array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} + */ + public function getList(string $scopeType, ?int $scopeId, string $listType): array + { + $ttl = (int) config('ranking.cache.ttl', 900); + $cacheKey = $this->cacheKey($scopeType, $scopeId, $listType); + $modelVer = config('ranking.model_version', 'rank_v1'); + + // 1. Cache + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + // 2. DB + $rankList = RankList::where('scope_type', $scopeType) + ->where('scope_id', RankList::resolveScope($scopeId)) + ->where('list_type', $listType) + ->where('model_version', $modelVer) + ->first(); + + if ($rankList !== null) { + $payload = [ + 'ids' => $rankList->artwork_ids, + 'computed_at' => $rankList->computed_at?->toIso8601String(), + 'model_version' => $rankList->model_version, + 'fallback' => false, + ]; + Cache::put($cacheKey, $payload, $ttl); + + return $payload; + } + + // 3. Fallback — latest published artworks + Log::info('RankingService: no rank list found, falling back to latest', [ + 'scope_type' => $scopeType, + 'scope_id' => $scopeId, + 'list_type' => $listType, + ]); + + $ids = $this->fallbackIds($scopeType, $scopeId); + + return [ + 'ids' => $ids, + 'computed_at' => null, + 'model_version' => 'fallback', + 'fallback' => true, + ]; + } + + /** + * Bust the Redis cache for a specific scope/type combination. + */ + public function bustCache(string $scopeType, ?int $scopeId, string $listType): void + { + Cache::forget($this->cacheKey($scopeType, $scopeId, $listType)); + } + + /** + * Bust all cache keys for a list type across scopes. + * (Convenience — used after full rebuild.) + */ + public function bustAllCaches(string $modelVersion): void + { + foreach (['trending', 'new_hot', 'best'] as $listType) { + Cache::forget($this->cacheKey('global', null, $listType)); + } + // Category and content_type caches are keyed with scope_id, so they expire + // naturally after TTL or get replaced on next request. + } + + /** + * Build the Redis cache key for a list. + * + * Format: rank:list:{scope_type}:{scope_id|global}:{list_type}:{model_version} + */ + public function cacheKey(string $scopeType, ?int $scopeId, string $listType): string + { + $prefix = config('ranking.cache.prefix', 'rank'); + $version = config('ranking.model_version', 'rank_v1'); + $sid = $scopeId !== null ? (string) $scopeId : 'global'; + + return "{$prefix}:list:{$scopeType}:{$sid}:{$listType}:{$version}"; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + /** + * Latest-first fallback IDs (public, approved artworks). + * Applies category/content_type filter when relevant. + * + * @return int[] + */ + private function fallbackIds(string $scopeType, ?int $scopeId): array + { + $listSize = (int) config('ranking.diversity.list_size', 50); + + $query = Artwork::query() + ->select('artworks.id') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNull('artworks.deleted_at') + ->whereNotNull('artworks.published_at') + ->orderByDesc('artworks.published_at') + ->limit($listSize); + + if ($scopeType === 'category' && $scopeId !== null) { + $query->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id') + ->where('artwork_category.category_id', $scopeId); + } + + if ($scopeType === 'content_type' && $scopeId !== null) { + $query->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id') + ->join('categories', 'categories.id', '=', 'artwork_category.category_id') + ->where('categories.content_type_id', $scopeId); + } + + return $query->pluck('artworks.id')->map(fn ($id) => (int) $id)->all(); + } + + // ── Signal query (used by RankComputeArtworkScoresJob) ───────────────── + + /** + * Return a query builder that selects all artwork signals needed for score + * computation. Results are NOT paginated — callers chunk them. + * + * Columns returned: + * id, user_id, published_at, is_public, is_approved, + * thumb_ext (→ has_thumbnail), + * views_7d, downloads_7d, views_24h, downloads_24h, + * views_all, downloads_all, favourites_all, + * favourites_7d, favourites_24h, downloads_24h, + * tag_count, + * age_hours + */ + public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder + { + return DB::table('artworks as a') + ->select([ + 'a.id', + 'a.user_id', + 'a.published_at', + 'a.is_public', + 'a.is_approved', + DB::raw('(a.thumb_ext IS NOT NULL AND a.thumb_ext != "") AS has_thumbnail'), + DB::raw('COALESCE(ast.views_7d, 0) AS views_7d'), + DB::raw('COALESCE(ast.downloads_7d, 0) AS downloads_7d'), + DB::raw('COALESCE(ast.views_24h, 0) AS views_24h'), + DB::raw('COALESCE(ast.downloads_24h, 0) AS downloads_24h'), + DB::raw('COALESCE(ast.views, 0) AS views_all'), + DB::raw('COALESCE(ast.downloads, 0) AS downloads_all'), + DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'), + DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'), + DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'), + DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'), + DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'), + ]) + ->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'a.id') + // Favourites (7 days) + ->leftJoinSub( + DB::table('artwork_favourites') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 7 DAY)')) + ->groupBy('artwork_id'), + 'fav7', + 'fav7.artwork_id', '=', 'a.id' + ) + // Favourites (24 hours) + ->leftJoinSub( + DB::table('artwork_favourites') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)')) + ->groupBy('artwork_id'), + 'fav1', + 'fav1.artwork_id', '=', 'a.id' + ) + // Tag count + ->leftJoinSub( + DB::table('artwork_tag') + ->select('artwork_id', DB::raw('COUNT(*) as tag_count')) + ->groupBy('artwork_id'), + 'tc', + 'tc.artwork_id', '=', 'a.id' + ) + ->where('a.is_public', 1) + ->where('a.is_approved', 1) + ->whereNull('a.deleted_at') + ->whereNotNull('a.published_at'); + } +} diff --git a/app/Services/TrendingService.php b/app/Services/TrendingService.php index 2b6266ec..f4d143c8 100644 --- a/app/Services/TrendingService.php +++ b/app/Services/TrendingService.php @@ -69,7 +69,7 @@ final class TrendingService ->whereNotNull('published_at') ->where('published_at', '>=', $cutoff) ->orderBy('id') - ->chunkById($chunkSize, function ($artworks) use ($column, &$updated): void { + ->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, &$updated): void { $ids = $artworks->pluck('id')->toArray(); $inClause = implode(',', array_fill(0, count($ids), '?')); diff --git a/config/ranking.php b/config/ranking.php new file mode 100644 index 00000000..71ad4a05 --- /dev/null +++ b/config/ranking.php @@ -0,0 +1,63 @@ + 'rank_v1', + + // ── Engagement signal weights (log-scaled) ────────────────────────────── + 'weights' => [ + 'views' => 1.0, + 'favourites' => 3.0, + 'downloads' => 2.5, + ], + + // ── Time-decay half-lives (hours) ─────────────────────────────────────── + 'half_life' => [ + 'trending' => 72, // Explore / global trending + 'new_hot' => 36, // New & Hot novelty feed + 'best' => 720, // Evergreen / Best-of (30 days) + 'category' => 96, // Per-category trending + ], + + // ── Novelty boost (New & Hot only) ────────────────────────────────────── + 'novelty_weight' => 0.35, + + // ── Quality modifiers ─────────────────────────────────────────────────── + 'quality' => [ + 'has_tags' => 0.05, + 'has_thumbnail' => 0.02, + 'tag_count_max' => 10, + 'tag_count_bonus' => 0.01, // per normalised tag fraction (max 0.01 total) + 'penalty_hidden' => 0.50, // deducted if hidden/inactive + ], + + // ── Diversity constraints ──────────────────────────────────────────────── + 'diversity' => [ + 'max_per_author' => 3, + 'list_size' => 50, + 'candidate_pool' => 200, // top N candidates to run diversity filter on + ], + + // ── Anti-spam / burst-view damping ────────────────────────────────────── + 'spam' => [ + 'views_24h_threshold' => 2000, + 'fav_ratio_threshold' => 0.002, + 'dl_ratio_threshold' => 0.001, + 'trending_penalty_factor' => 0.5, + ], + + // ── Redis cache ───────────────────────────────────────────────────────── + 'cache' => [ + 'ttl' => 900, // seconds (15 min) — lists are rebuilt hourly + 'prefix' => 'rank', + ], +]; diff --git a/database/migrations/2026_02_27_500000_create_rank_artwork_scores_table.php b/database/migrations/2026_02_27_500000_create_rank_artwork_scores_table.php new file mode 100644 index 00000000..8e3773a1 --- /dev/null +++ b/database/migrations/2026_02_27_500000_create_rank_artwork_scores_table.php @@ -0,0 +1,38 @@ +unsignedBigInteger('artwork_id')->primary(); + $table->foreign('artwork_id')->references('id')->on('artworks')->cascadeOnDelete(); + + $table->double('score_trending', 10, 6)->default(0)->index(); + $table->double('score_new_hot', 10, 6)->default(0)->index(); + $table->double('score_best', 10, 6)->default(0)->index(); + + $table->string('model_version', 32)->default('rank_v1')->index(); + $table->timestamp('computed_at')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rank_artwork_scores'); + } +}; diff --git a/database/migrations/2026_02_27_500001_create_rank_lists_table.php b/database/migrations/2026_02_27_500001_create_rank_lists_table.php new file mode 100644 index 00000000..20d9ed67 --- /dev/null +++ b/database/migrations/2026_02_27_500001_create_rank_lists_table.php @@ -0,0 +1,44 @@ +id(); + + $table->string('scope_type', 32)->index(); // global | category | content_type + $table->unsignedBigInteger('scope_id')->default(0)->index(); // 0 = global + $table->string('list_type', 32)->index(); // trending | new_hot | best + $table->string('model_version', 32)->default('rank_v1'); + + $table->json('artwork_ids'); // ordered list of ids + $table->timestamp('computed_at')->nullable(); + + $table->unique( + ['scope_type', 'scope_id', 'list_type', 'model_version'], + 'rank_lists_scope_unique' + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('rank_lists'); + } +}; diff --git a/resources/js/Pages/ArtworkPage.jsx b/resources/js/Pages/ArtworkPage.jsx index 71f23760..0c1f9a62 100644 --- a/resources/js/Pages/ArtworkPage.jsx +++ b/resources/js/Pages/ArtworkPage.jsx @@ -21,6 +21,17 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present // Navigable state — updated on client-side navigation const [artwork, setArtwork] = useState(initialArtwork) + const [liveStats, setLiveStats] = useState(initialArtwork?.stats || {}) + + const handleStatsChange = useCallback((delta) => { + setLiveStats(prev => { + const next = { ...prev } + Object.entries(delta).forEach(([key, val]) => { + next[key] = Math.max(0, (Number(next[key]) || 0) + val) + }) + return next + }) + }, []) const [presentMd, setPresentMd] = useState(initialMd) const [presentLg, setPresentLg] = useState(initialLg) const [presentXl, setPresentXl] = useState(initialXl) @@ -38,6 +49,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present */ const handleNavigate = useCallback((data) => { setArtwork(data) + setLiveStats(data.stats || {}) setPresentMd(data.thumbs?.md ?? null) setPresentLg(data.thumbs?.lg ?? null) setPresentXl(data.thumbs?.xl ?? null) @@ -69,14 +81,14 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
- +
- + @@ -91,7 +103,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present diff --git a/resources/js/components/artwork/ArtworkActions.jsx b/resources/js/components/artwork/ArtworkActions.jsx index 2afd6300..8e055c86 100644 --- a/resources/js/components/artwork/ArtworkActions.jsx +++ b/resources/js/components/artwork/ArtworkActions.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' -export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) { +export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false, onStatsChange }) { const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked)) const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited)) const [reporting, setReporting] = useState(false) @@ -17,11 +17,16 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = if (!artwork?.id) return const key = `sb_viewed_${artwork.id}` if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return - if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1') fetch(`/api/art/${artwork.id}/view`, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, credentials: 'same-origin', + }).then(res => { + // Only mark as seen after a confirmed success — if the POST fails the + // next page load will retry rather than silently skipping forever. + if (res.ok && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(key, '1') + } }).catch(() => {}) }, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps @@ -81,6 +86,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = setLiked(nextState) try { await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState }) + onStatsChange?.({ likes: nextState ? 1 : -1 }) } catch { setLiked(!nextState) } @@ -91,6 +97,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = setFavorited(nextState) try { await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState }) + onStatsChange?.({ favorites: nextState ? 1 : -1 }) } catch { setFavorited(!nextState) } diff --git a/resources/js/components/artwork/ArtworkStats.jsx b/resources/js/components/artwork/ArtworkStats.jsx index 5e93aecb..56b22ff5 100644 --- a/resources/js/components/artwork/ArtworkStats.jsx +++ b/resources/js/components/artwork/ArtworkStats.jsx @@ -7,8 +7,8 @@ function formatCount(value) { return `${number}` } -export default function ArtworkStats({ artwork }) { - const stats = artwork?.stats || {} +export default function ArtworkStats({ artwork, stats: statsProp }) { + const stats = statsProp || artwork?.stats || {} const width = artwork?.dimensions?.width || 0 const height = artwork?.dimensions?.height || 0 diff --git a/resources/js/components/gallery/ArtworkCard.jsx b/resources/js/components/gallery/ArtworkCard.jsx index 57438cea..7c8e26ef 100644 --- a/resources/js/components/gallery/ArtworkCard.jsx +++ b/resources/js/components/gallery/ArtworkCard.jsx @@ -1,11 +1,5 @@ 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, ''); } @@ -15,17 +9,8 @@ function slugify(str) { * 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 imgRef = useRef(null); + const mediaRef = useRef(null); const title = (art.name || art.title || 'Untitled artwork').trim(); const author = (art.uname || art.author_name || art.author || 'Skinbase').trim(); @@ -40,11 +25,35 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul 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); + // Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default() + const cdnBase = 'https://files.skinbase.org'; + const avatarSrc = art.avatar_url || `${cdnBase}/avatars/default.webp`; const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0; const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null; + // Activate blur-preview class once image has decoded (mirrors nova.js behaviour). + // If the server didn't supply dimensions (old artworks with width=0/height=0), + // read naturalWidth/naturalHeight from the loaded image and imperatively set + // the container's aspect-ratio so the masonry ResizeObserver picks up real proportions. + useEffect(() => { + const img = imgRef.current; + const media = mediaRef.current; + if (!img) return; + + const markLoaded = () => { + img.classList.add('is-loaded'); + // If no server-side dimensions, apply real ratio from the decoded image + if (media && !hasDimensions && img.naturalWidth > 0 && img.naturalHeight > 0) { + media.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`; + } + }; + + if (img.complete && img.naturalWidth > 0) { markLoaded(); return; } + img.addEventListener('load', markLoaded, { once: true }); + img.addEventListener('error', markLoaded, { once: true }); + }, []); + // 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']; @@ -63,6 +72,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul // positioning means width/height are always 100% of the capped box, so // object-cover crops top/bottom instead of leaving dark gaps. const imgClass = [ + 'nova-card-main-image', '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' : '', @@ -76,7 +86,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul return (
- {category && ( -
- {category} -
- )} - {/* 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. */}
); } diff --git a/resources/js/components/gallery/CategoryPillCarousel.css b/resources/js/components/gallery/CategoryPillCarousel.css new file mode 100644 index 00000000..f1454bef --- /dev/null +++ b/resources/js/components/gallery/CategoryPillCarousel.css @@ -0,0 +1,158 @@ +.nb-react-carousel { + position: relative; + display: flex; + align-items: center; + min-height: 56px; + overflow: hidden; +} + +.nb-react-viewport { + flex: 1 1 auto; + overflow: hidden; + height: 100%; + display: flex; + align-items: center; + white-space: nowrap; +} + +.nb-react-strip { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap !important; + white-space: nowrap; + width: max-content; + min-width: max-content; + max-width: none; + padding: 0.6rem 3rem; + will-change: transform; + transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1); + cursor: grab; + user-select: none; + touch-action: pan-x; +} + +.nb-react-strip.is-dragging { + transition: none; + cursor: grabbing; +} + +.nb-react-fade { + position: absolute; + top: 0; + bottom: 0; + width: 80px; + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease; +} + +.nb-react-fade--left { + left: 0; + background: linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%); +} + +.nb-react-fade--right { + right: 0; + background: linear-gradient(to left, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%); +} + +.nb-react-carousel:not(.at-start) .nb-react-fade--left { + opacity: 1; +} + +.nb-react-carousel:not(.at-end) .nb-react-fade--right { + opacity: 1; +} + +.nb-react-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 9999px; + background: rgba(15,23,36,0.9); + border: 1px solid rgba(255,255,255,0.18); + color: rgba(255,255,255,0.85); + cursor: pointer; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 200ms ease, background 150ms ease, transform 150ms ease; + -webkit-tap-highlight-color: transparent; +} + +.nb-react-arrow--left { + left: 8px; +} + +.nb-react-arrow--right { + right: 8px; +} + +.nb-react-arrow:hover { + background: rgba(30,46,68,0.98); + color: #fff; + transform: translateY(-50%) scale(1.1); +} + +.nb-react-arrow:active { + transform: translateY(-50%) scale(0.93); +} + +.nb-react-carousel:not(.at-start) .nb-react-arrow--left { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.nb-react-carousel:not(.at-end) .nb-react-arrow--right { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.nb-react-pill { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + line-height: 1; + border-radius: 9999px; + padding: 0.35rem 1rem; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap !important; + text-decoration: none; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.08); + color: rgba(200,215,230,0.85); + transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease, box-shadow 150ms ease; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.nb-react-pill:hover { + background: rgba(255,255,255,0.15); + border-color: rgba(255,255,255,0.25); + color: #fff; + transform: translateY(-1px); +} + +.nb-react-pill--active { + background: linear-gradient(135deg, #E07A21 0%, #c9650f 100%); + border-color: rgba(224,122,33,0.6); + color: #fff; + box-shadow: 0 2px 12px rgba(224,122,33,0.35), 0 0 0 1px rgba(224,122,33,0.2) inset; + transform: none; +} + +.nb-react-pill--active:hover { + background: linear-gradient(135deg, #f08830 0%, #d9720f 100%); + transform: none; +} diff --git a/resources/js/components/gallery/CategoryPillCarousel.jsx b/resources/js/components/gallery/CategoryPillCarousel.jsx new file mode 100644 index 00000000..12c08ae4 --- /dev/null +++ b/resources/js/components/gallery/CategoryPillCarousel.jsx @@ -0,0 +1,282 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import './CategoryPillCarousel.css'; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +export default function CategoryPillCarousel({ + items = [], + ariaLabel = 'Filter by category', + className = '', +}) { + const viewportRef = useRef(null); + const stripRef = useRef(null); + const animationRef = useRef(0); + const dragStateRef = useRef({ + active: false, + moved: false, + pointerId: null, + startX: 0, + startOffset: 0, + }); + + const [offset, setOffset] = useState(0); + const [dragging, setDragging] = useState(false); + const [maxScroll, setMaxScroll] = useState(0); + + const activeIndex = useMemo(() => { + const idx = items.findIndex((item) => !!item.active); + return idx >= 0 ? idx : 0; + }, [items]); + + const maxOffset = useCallback(() => { + const viewport = viewportRef.current; + const strip = stripRef.current; + if (!viewport || !strip) return 0; + return Math.max(0, strip.scrollWidth - viewport.clientWidth); + }, []); + + const recalcBounds = useCallback(() => { + const max = maxOffset(); + setMaxScroll(max); + setOffset((prev) => clamp(prev, -max, 0)); + }, [maxOffset]); + + const moveTo = useCallback((nextOffset) => { + const max = maxOffset(); + const clamped = clamp(nextOffset, -max, 0); + setOffset(clamped); + }, [maxOffset]); + + const animateTo = useCallback((targetOffset, duration = 380) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = 0; + } + + const max = maxOffset(); + const target = clamp(targetOffset, -max, 0); + const start = offset; + const delta = target - start; + + if (Math.abs(delta) < 1) { + setOffset(target); + return; + } + + const startTime = performance.now(); + setDragging(false); + + const easeOutCubic = (t) => 1 - ((1 - t) ** 3); + + const step = (now) => { + const elapsed = now - startTime; + const progress = Math.min(1, elapsed / duration); + const eased = easeOutCubic(progress); + setOffset(start + (delta * eased)); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(step); + } else { + animationRef.current = 0; + setOffset(target); + } + }; + + animationRef.current = requestAnimationFrame(step); + }, [maxOffset, offset]); + + const moveToPill = useCallback((direction) => { + const strip = stripRef.current; + if (!strip) return; + + const pills = Array.from(strip.querySelectorAll('.nb-react-pill')); + if (!pills.length) return; + + const viewLeft = -offset; + if (direction > 0) { + const next = pills.find((pill) => pill.offsetLeft > viewLeft + 6); + if (next) animateTo(-next.offsetLeft); + else animateTo(-maxOffset()); + return; + } + + for (let i = pills.length - 1; i >= 0; i -= 1) { + const left = pills[i].offsetLeft; + if (left < viewLeft - 6) { + animateTo(-left); + return; + } + } + animateTo(0); + }, [animateTo, maxOffset, offset]); + + useEffect(() => { + const viewport = viewportRef.current; + const strip = stripRef.current; + if (!viewport || !strip) return; + + const activeEl = strip.querySelector('[data-active-pill="true"]'); + if (!activeEl) { + moveTo(0); + return; + } + + const centered = -(activeEl.offsetLeft - (viewport.clientWidth / 2) + (activeEl.offsetWidth / 2)); + moveTo(centered); + recalcBounds(); + }, [activeIndex, items, moveTo, recalcBounds]); + + useEffect(() => { + const viewport = viewportRef.current; + const strip = stripRef.current; + if (!viewport || !strip) return; + + const measure = () => recalcBounds(); + + const rafId = requestAnimationFrame(measure); + window.addEventListener('resize', measure, { passive: true }); + + let ro = null; + if ('ResizeObserver' in window) { + ro = new ResizeObserver(measure); + ro.observe(viewport); + ro.observe(strip); + } + + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener('resize', measure); + if (ro) ro.disconnect(); + }; + }, [items, recalcBounds]); + + useEffect(() => { + const strip = stripRef.current; + if (!strip) return; + + const onPointerDown = (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = 0; + } + + dragStateRef.current.active = true; + dragStateRef.current.moved = false; + dragStateRef.current.pointerId = event.pointerId; + dragStateRef.current.startX = event.clientX; + dragStateRef.current.startOffset = offset; + + setDragging(true); + + if (strip.setPointerCapture) { + try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ } + } + + event.preventDefault(); + }; + + const onPointerMove = (event) => { + const state = dragStateRef.current; + if (!state.active || state.pointerId !== event.pointerId) return; + + const dx = event.clientX - state.startX; + if (Math.abs(dx) > 3) state.moved = true; + moveTo(state.startOffset + dx); + }; + + const onPointerUpOrCancel = (event) => { + const state = dragStateRef.current; + if (!state.active || state.pointerId !== event.pointerId) return; + + state.active = false; + state.pointerId = null; + setDragging(false); + + if (strip.releasePointerCapture) { + try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ } + } + }; + + const onClickCapture = (event) => { + if (!dragStateRef.current.moved) return; + event.preventDefault(); + event.stopPropagation(); + dragStateRef.current.moved = false; + }; + + strip.addEventListener('pointerdown', onPointerDown); + strip.addEventListener('pointermove', onPointerMove); + strip.addEventListener('pointerup', onPointerUpOrCancel); + strip.addEventListener('pointercancel', onPointerUpOrCancel); + strip.addEventListener('click', onClickCapture, true); + + return () => { + strip.removeEventListener('pointerdown', onPointerDown); + strip.removeEventListener('pointermove', onPointerMove); + strip.removeEventListener('pointerup', onPointerUpOrCancel); + strip.removeEventListener('pointercancel', onPointerUpOrCancel); + strip.removeEventListener('click', onClickCapture, true); + }; + }, [moveTo, offset]); + + useEffect(() => () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = 0; + } + }, []); + + const max = maxScroll; + const atStart = offset >= -2; + const atEnd = offset <= -(max - 2); + + return ( +
+