feat: add reusable gallery carousel and ranking feed infrastructure

This commit is contained in:
2026-02-28 07:56:25 +01:00
parent 67ef79766c
commit 6536d4ae78
36 changed files with 3177 additions and 373 deletions

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
/**
* Configure the Meilisearch artworks index:
* sortable attributes (all fields used in category/discover sorts)
* filterable attributes (used in search filters)
*
* Run after any schema / toSearchableArray change:
* php artisan meilisearch:configure-index
*/
class ConfigureMeilisearchIndex extends Command
{
protected $signature = 'meilisearch:configure-index {--index=artworks : Meilisearch index name}';
protected $description = 'Push sortable and filterable attribute settings to the Meilisearch artworks index.';
/**
* Fields that can be used as sort targets in Artwork::search()->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;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Copy views and downloads from the legacy `wallz` table into `artwork_stats`.
*
* Uses wallz.id as artwork_id.
* Rows that already exist are updated; missing rows are inserted with zeros
* for all other counters.
*
* Usage:
* php artisan skinbase:migrate-wallz-stats
* php artisan skinbase:migrate-wallz-stats --chunk=500 --dry-run
*/
class MigrateWallzStatsCommand extends Command
{
protected $signature = 'skinbase:migrate-wallz-stats
{--chunk=1000 : Number of wallz rows to process per batch}
{--dry-run : Preview counts without writing to the database}';
protected $description = 'Import views and downloads from legacy wallz table into artwork_stats';
public function handle(): int
{
$chunkSize = (int) $this->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;
}
}

View File

@@ -13,6 +13,8 @@ use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\RecalculateTrendingCommand; use App\Console\Commands\RecalculateTrendingCommand;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand; use App\Uploads\Commands\CleanupUploadsCommand;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
@@ -51,6 +53,12 @@ class Kernel extends ConsoleKernel
// Recalculate trending scores every 30 minutes (staggered to reduce peak load) // 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=24h')->everyThirtyMinutes();
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground(); $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();
} }
/** /**

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\RankingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* RankController
*
* Serves pre-computed ranked artwork lists.
*
* Endpoints:
* GET /api/rank/global?type=trending|new_hot|best
* GET /api/rank/category/{id}?type=trending|new_hot|best
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
*/
class RankController extends Controller
{
public function __construct(private readonly RankingService $ranking) {}
/**
* GET /api/rank/global
*
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
*/
public function global(Request $request): AnonymousResourceCollection|JsonResponse
{
$listType = $this->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;
}
}

View File

@@ -5,10 +5,12 @@ namespace App\Http\Controllers\Web;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkService;
use App\Services\ArtworkSearchService; use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Pagination\AbstractPaginator; use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\AbstractCursorPaginator; use Illuminate\Pagination\AbstractCursorPaginator;
@@ -16,11 +18,56 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
{ {
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other']; 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 = [ private const SORT_MAP = [
'latest' => 'created_at:desc', // ── Nova sort aliases ─────────────────────────────────────────────────
'popular' => 'views:desc', // trending_score_24h only covers artworks ≤ 7 days old; use 7d score
'liked' => 'likes:desc', // and favorites_count as fallbacks so older artworks don't all tie at 0.
'downloads' => 'downloads:desc', '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( public function __construct(
@@ -31,13 +78,20 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
public function browse(Request $request) public function browse(Request $request)
{ {
$sort = (string) $request->query('sort', 'latest'); $sort = $this->resolveSort($request, 'trending');
$perPage = $this->resolvePerPage($request); $perPage = $this->resolvePerPage($request);
$page = (int) $request->query('page', 1);
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$artworks = Artwork::search('')->options([ $artworks = Cache::remember(
"browse.all.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true', 'filter' => 'is_public = true AND is_approved = true',
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'], 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage); ])->paginate($perPage)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks); $seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
$mainCategories = $this->mainCategories(); $mainCategories = $this->mainCategories();
@@ -49,6 +103,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'contentType' => null, 'contentType' => null,
'category' => null, 'category' => null,
'artworks' => $artworks, 'artworks' => $artworks,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Browse Artworks', 'hero_title' => 'Browse Artworks',
'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.', 'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.',
'breadcrumbs' => collect(), 'breadcrumbs' => collect(),
@@ -74,18 +130,26 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404); abort(404);
} }
$sort = (string) $request->query('sort', 'latest'); // Default sort: trending (not chronological)
$sort = $this->resolveSort($request, 'trending');
$perPage = $this->resolvePerPage($request); $perPage = $this->resolvePerPage($request);
$page = (int) $request->query('page', 1);
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$mainCategories = $this->mainCategories(); $mainCategories = $this->mainCategories();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$normalizedPath = trim((string) $path, '/'); $normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') { if ($normalizedPath === '') {
$artworks = Artwork::search('')->options([ $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 . '"', 'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'], 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage); ])->paginate($perPage)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
return view('gallery.index', [ return view('gallery.index', [
@@ -95,11 +159,13 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'contentType' => $contentType, 'contentType' => $contentType,
'category' => null, 'category' => null,
'artworks' => $artworks, 'artworks' => $artworks,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $contentType->name, 'hero_title' => $contentType->name,
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'), 'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]), 'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]),
'page_title' => $contentType->name, 'page_title' => $contentType->name . ' Skinbase Nova',
'page_meta_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase'), '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_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'], 'page_rel_prev' => $seo['prev'],
@@ -114,10 +180,16 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404); abort(404);
} }
$artworks = Artwork::search('')->options([ $catSlug = $category->slug;
'filter' => 'is_public = true AND is_approved = true AND category = "' . $category->slug . '"', $artworks = Cache::remember(
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'], "gallery.cat.{$catSlug}.{$sort}.{$page}",
])->paginate($perPage); $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); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
@@ -140,11 +212,13 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'contentType' => $contentType, 'contentType' => $contentType,
'category' => $category, 'category' => $category,
'artworks' => $artworks, 'artworks' => $artworks,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $category->name, 'hero_title' => $category->name,
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'), 'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => $breadcrumbs, 'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name, 'page_title' => $category->name . ' Skinbase Nova',
'page_meta_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase'), '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_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'], 'page_rel_prev' => $seo['prev'],
@@ -211,16 +285,53 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return redirect($target, 301); 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 private function resolvePerPage(Request $request): int
{ {
$limit = (int) $request->query('limit', 0); $limit = (int) $request->query('limit', 0);
$perPage = (int) $request->query('per_page', 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)); 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 private function mainCategories(): Collection
{ {
return ContentType::orderBy('id') return ContentType::orderBy('id')

View File

@@ -38,7 +38,7 @@ final class DiscoverController extends Controller
{ {
$perPage = 24; $perPage = 24;
$results = $this->searchService->discoverTrending($perPage); $results = $this->searchService->discoverTrending($perPage);
$artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
@@ -55,7 +55,7 @@ final class DiscoverController extends Controller
{ {
$perPage = 24; $perPage = 24;
$results = $this->searchService->discoverFresh($perPage); $results = $this->searchService->discoverFresh($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
@@ -72,7 +72,7 @@ final class DiscoverController extends Controller
{ {
$perPage = 24; $perPage = 24;
$results = $this->searchService->discoverTopRated($perPage); $results = $this->searchService->discoverTopRated($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
@@ -89,7 +89,7 @@ final class DiscoverController extends Controller
{ {
$perPage = 24; $perPage = 24;
$results = $this->searchService->discoverMostDownloaded($perPage); $results = $this->searchService->discoverMostDownloaded($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
@@ -110,7 +110,11 @@ final class DiscoverController extends Controller
$artworks = Artwork::query() $artworks = Artwork::query()
->public() ->public()
->published() ->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('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day]) ->whereRaw('DAY(published_at) = ?', [$today->day])
->whereRaw('YEAR(published_at) < ?', [$today->year]) ->whereRaw('YEAR(published_at) < ?', [$today->year])
@@ -206,16 +210,27 @@ final class DiscoverController extends Controller
$artworkItems = $feedResult['data'] ?? []; $artworkItems = $feedResult['data'] ?? [];
// Build a simple presentable collection // Build a simple presentable collection
$artworks = collect($artworkItems)->map(fn (array $item) => (object) [ $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, 'id' => $item['id'] ?? 0,
'name' => $item['title'] ?? 'Untitled', 'name' => $item['title'] ?? 'Untitled',
'category_name' => '', 'category_name' => $item['category_name'] ?? '',
'category_slug' => $item['category_slug'] ?? '',
'thumb_url' => $item['thumbnail_url'] ?? null, 'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_url'] ?? null, 'thumb_srcset' => $item['thumbnail_url'] ?? null,
'uname' => $item['author'] ?? 'Artist', 'uname' => $item['author'] ?? 'Artist',
'published_at' => null, 'username' => $item['username'] ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $item['published_at'] ?? null,
'slug' => $item['slug'] ?? '', 'slug' => $item['slug'] ?? '',
]); 'width' => $width,
'height' => $height,
];
});
$meta = $feedResult['meta'] ?? []; $meta = $feedResult['meta'] ?? [];
$nextCursor = $meta['next_cursor'] ?? null; $nextCursor = $meta['next_cursor'] ?? null;
@@ -308,10 +323,73 @@ final class DiscoverController extends Controller
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── 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 private function presentArtwork(Artwork $artwork): object
{ {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md'); $present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [ return (object) [
'id' => $artwork->id, 'id' => $artwork->id,
@@ -322,6 +400,7 @@ final class DiscoverController extends Controller
'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',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at, 'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null, 'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null, 'height' => $artwork->height ?? null,

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\RankList;
use App\Services\RankingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankBuildListsJob
*
* Runs hourly (after RankComputeArtworkScoresJob).
*
* Builds ordered artwork_id arrays for:
* global trending, new_hot, best
* each category trending, new_hot, best
* each content_type trending, new_hot, best
*
* Applies author-diversity cap (max 3 per author in a list of 50).
* Stores results in rank_lists and busts relevant Redis keys.
*/
class RankBuildListsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800;
public int $tries = 2;
private const LIST_TYPES = ['trending', 'new_hot', 'best'];
public function handle(RankingService $ranking): void
{
$modelVersion = config('ranking.model_version', 'rank_v1');
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
$listSize = (int) config('ranking.diversity.list_size', 50);
$candidatePool = (int) config('ranking.diversity.candidate_pool', 200);
$listsBuilt = 0;
// ── 1. Global ──────────────────────────────────────────────────────
foreach (self::LIST_TYPES as $listType) {
$this->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<int, object>
*/
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',
};
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\RankingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankComputeArtworkScoresJob
*
* Runs hourly. Queries raw artwork signals (views, favourites, downloads,
* age, tags) in batches, computes the three ranking scores using
* RankingService::computeScores(), and bulk-upserts the results into
* rank_artwork_scores.
*
* No N+1: all signals are resolved via a single pre-aggregated JOIN query,
* chunked by artwork id.
*/
class RankComputeArtworkScoresJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30 min max
public int $tries = 2;
private const CHUNK_SIZE = 500;
public function handle(RankingService $ranking): void
{
$modelVersion = config('ranking.model_version', 'rank_v1');
$total = 0;
$now = now()->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,
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\RankArtworkScore
*
* Materialised ranking scores for a single artwork.
* Rebuilt hourly by RankComputeArtworkScoresJob.
*
* @property int $artwork_id
* @property float $score_trending
* @property float $score_new_hot
* @property float $score_best
* @property string $model_version
* @property \Carbon\Carbon|null $computed_at
*/
class RankArtworkScore extends Model
{
protected $table = 'rank_artwork_scores';
/** Artwork_id is the primary key; no auto-increment. */
protected $primaryKey = 'artwork_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'artwork_id',
'score_trending',
'score_new_hot',
'score_best',
'model_version',
'computed_at',
];
protected $casts = [
'artwork_id' => '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',
};
}
}

53
app/Models/RankList.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* App\Models\RankList
*
* Stores an ordered list of artwork IDs for a feed surface.
* Rebuilt hourly by RankBuildListsJob.
*
* scope_id = 0 is the sentinel for "global" scope.
*
* @property int $id
* @property string $scope_type global | category | content_type
* @property int $scope_id 0 = global; category.id or content_type.id otherwise
* @property string $list_type trending | new_hot | best
* @property string $model_version
* @property array $artwork_ids Ordered array of artwork IDs
* @property \Carbon\Carbon|null $computed_at
*/
class RankList extends Model
{
protected $table = 'rank_lists';
public $timestamps = false;
protected $fillable = [
'scope_type',
'scope_id',
'list_type',
'model_version',
'artwork_ids',
'computed_at',
];
protected $casts = [
'scope_id' => '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;
}
}

View File

@@ -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. * Related artworks: same tags, different artwork, ranked by views + likes.
* Limit 12. * Limit 12.

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\RankArtworkScore;
use App\Models\RankList;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankingService Skinbase Nova rank_v1
*
* Responsibilities:
* 1. Score computation turn raw artwork signals into three float scores.
* 2. Diversity filtering cap items per author while keeping rank order.
* 3. List read / cache serve ranked lists from Redis, falling back to DB,
* and ultimately to latest-first if no list is built yet.
*/
final class RankingService
{
// ── Score computation ──────────────────────────────────────────────────
/**
* Compute all three ranking scores for a single artwork data row.
*
* @param object $row stdClass with fields:
* views_7d, favourites_7d, downloads_7d,
* views_all, favourites_all, downloads_all,
* views_24h, favourites_24h, downloads_24h,
* age_hours, tag_count, has_thumbnail (bool 0/1),
* is_public, is_approved
* @return array{score_trending: float, score_new_hot: float, score_best: float}
*/
public function computeScores(object $row): array
{
$cfg = config('ranking');
$wV = (float) $cfg['weights']['views'];
$wF = (float) $cfg['weights']['favourites'];
$wD = (float) $cfg['weights']['downloads'];
// 3.1 Base engagement (7-day window)
$E = ($wV * log(1 + (float) $row->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');
}
}

View File

@@ -69,7 +69,7 @@ final class TrendingService
->whereNotNull('published_at') ->whereNotNull('published_at')
->where('published_at', '>=', $cutoff) ->where('published_at', '>=', $cutoff)
->orderBy('id') ->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(); $ids = $artworks->pluck('id')->toArray();
$inClause = implode(',', array_fill(0, count($ids), '?')); $inClause = implode(',', array_fill(0, count($ids), '?'));

63
config/ranking.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* Ranking system configuration Skinbase Nova rank_v1
*
* All weights, half-lives, and thresholds are tunable here.
* Increment model_version when changing weights so caches expire gracefully.
*/
return [
// ── Model versioning ────────────────────────────────────────────────────
'model_version' => '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',
],
];

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Materialised ranking score table.
*
* Stores three pre-computed scores per artwork:
* score_trending time-decayed engagement (HL=72h)
* score_new_hot short novelty boost (HL=36h, first 48h emphasis)
* score_best slow-decay evergreen (HL=720h)
*
* Rebuilt hourly by RankComputeArtworkScoresJob.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('rank_artwork_scores', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ranked list cache table.
*
* Stores ordered artwork_id JSON arrays for each feed surface:
* scope_type : global | category | content_type
* scope_id : 0 for global, foreign-key id for category / content_type
* list_type : trending | new_hot | best
*
* Rebuilt hourly by RankBuildListsJob.
* scope_id uses 0 as sentinel for "global" (avoids nullable unique-key pitfalls in MySQL).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('rank_lists', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -21,6 +21,17 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
// Navigable state — updated on client-side navigation // Navigable state — updated on client-side navigation
const [artwork, setArtwork] = useState(initialArtwork) 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 [presentMd, setPresentMd] = useState(initialMd)
const [presentLg, setPresentLg] = useState(initialLg) const [presentLg, setPresentLg] = useState(initialLg)
const [presentXl, setPresentXl] = useState(initialXl) const [presentXl, setPresentXl] = useState(initialXl)
@@ -38,6 +49,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
*/ */
const handleNavigate = useCallback((data) => { const handleNavigate = useCallback((data) => {
setArtwork(data) setArtwork(data)
setLiveStats(data.stats || {})
setPresentMd(data.thumbs?.md ?? null) setPresentMd(data.thumbs?.md ?? null)
setPresentLg(data.thumbs?.lg ?? null) setPresentLg(data.thumbs?.lg ?? null)
setPresentXl(data.thumbs?.xl ?? null) setPresentXl(data.thumbs?.xl ?? null)
@@ -69,14 +81,14 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
<div className="mt-6 space-y-4 lg:hidden"> <div className="mt-6 space-y-4 lg:hidden">
<ArtworkAuthor artwork={artwork} presentSq={presentSq} /> <ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority /> <ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority onStatsChange={handleStatsChange} />
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} /> <ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</div> </div>
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3"> <div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
<ArtworkMeta artwork={artwork} /> <ArtworkMeta artwork={artwork} />
<ArtworkStats artwork={artwork} /> <ArtworkStats artwork={artwork} stats={liveStats} />
<ArtworkTags artwork={artwork} /> <ArtworkTags artwork={artwork} />
<ArtworkDescription artwork={artwork} /> <ArtworkDescription artwork={artwork} />
<ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} /> <ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} />
@@ -91,7 +103,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
<aside className="hidden space-y-6 lg:block"> <aside className="hidden space-y-6 lg:block">
<div className="sticky top-24 space-y-4"> <div className="sticky top-24 space-y-4">
<ArtworkAuthor artwork={artwork} presentSq={presentSq} /> <ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} /> <ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} onStatsChange={handleStatsChange} />
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} /> <ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</div> </div>
</aside> </aside>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react' 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 [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited)) const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [reporting, setReporting] = useState(false) const [reporting, setReporting] = useState(false)
@@ -17,11 +17,16 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
if (!artwork?.id) return if (!artwork?.id) return
const key = `sb_viewed_${artwork.id}` const key = `sb_viewed_${artwork.id}`
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
fetch(`/api/art/${artwork.id}/view`, { fetch(`/api/art/${artwork.id}/view`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin', 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(() => {}) }).catch(() => {})
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps }, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -81,6 +86,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
setLiked(nextState) setLiked(nextState)
try { try {
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState }) await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
onStatsChange?.({ likes: nextState ? 1 : -1 })
} catch { } catch {
setLiked(!nextState) setLiked(!nextState)
} }
@@ -91,6 +97,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
setFavorited(nextState) setFavorited(nextState)
try { try {
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState }) await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
onStatsChange?.({ favorites: nextState ? 1 : -1 })
} catch { } catch {
setFavorited(!nextState) setFavorited(!nextState)
} }

View File

@@ -7,8 +7,8 @@ function formatCount(value) {
return `${number}` return `${number}`
} }
export default function ArtworkStats({ artwork }) { export default function ArtworkStats({ artwork, stats: statsProp }) {
const stats = artwork?.stats || {} const stats = statsProp || artwork?.stats || {}
const width = artwork?.dimensions?.width || 0 const width = artwork?.dimensions?.width || 0
const height = artwork?.dimensions?.height || 0 const height = artwork?.dimensions?.height || 0

View File

@@ -1,11 +1,5 @@
import React, { useEffect, useRef } from 'react'; 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) { function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
} }
@@ -16,16 +10,7 @@ function slugify(str) {
*/ */
export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) { export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) {
const imgRef = useRef(null); const imgRef = useRef(null);
const mediaRef = 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 title = (art.name || art.title || 'Untitled artwork').trim();
const author = (art.uname || art.author_name || art.author || 'Skinbase').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 cardUrl = art.url || (art.id ? `/art/${art.id}/${slugify(title)}` : '#');
const authorUrl = username ? `/@${username.toLowerCase()}` : null; 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 hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null; 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. // 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. // These slugs match the root categories; name-matching is kept as fallback.
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper']; 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 // positioning means width/height are always 100% of the capped box, so
// object-cover crops top/bottom instead of leaving dark gaps. // object-cover crops top/bottom instead of leaving dark gaps.
const imgClass = [ const imgClass = [
'nova-card-main-image',
'absolute inset-0 h-full w-full object-cover', 'absolute inset-0 h-full w-full object-cover',
'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]', 'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]',
loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '', loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '',
@@ -76,7 +86,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
return ( return (
<article <article
className={`nova-card gallery-item artwork${isWideEligible ? ' nova-card--wide' : ''}`} className={`nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`}
style={articleStyle} style={articleStyle}
data-art-id={art.id} data-art-id={art.id}
data-art-url={cardUrl} data-art-url={cardUrl}
@@ -85,17 +95,18 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
> >
<a <a
href={cardUrl} 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" className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20
shadow-lg shadow-black/40
transition-all duration-300 ease-out
hover:scale-[1.02] hover:-translate-y-px hover:ring-white/15
hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
style={{ willChange: 'transform' }}
> >
{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. {/* 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. */} w-full prevents browsers shrinking the width when max-height overrides aspect-ratio. */}
<div <div
ref={mediaRef}
className="nova-card-media relative w-full overflow-hidden bg-neutral-900" className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
style={aspectStyle} style={aspectStyle}
> >
@@ -116,18 +127,6 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
data-blur-preview={loading !== 'eager' ? '' : undefined} 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 */} {/* 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="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="truncate text-sm font-semibold text-white">{title}</div>
@@ -136,7 +135,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
<img <img
src={avatarSrc} src={avatarSrc}
alt={`Avatar of ${author}`} alt={`Avatar of ${author}`}
className="w-6 h-6 rounded-full object-cover" className="w-6 h-6 shrink-0 rounded-full object-cover"
loading="lazy" loading="lazy"
/> />
<span className="truncate"> <span className="truncate">
@@ -158,6 +157,41 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
<span className="sr-only">{title} by {author}</span> <span className="sr-only">{title} by {author}</span>
</a> </a>
{/* ── Quick actions: top-right, shown on card hover via CSS ─────── */}
<div className="nb-card-actions" aria-hidden="true">
<button
type="button"
className="nb-card-action-btn"
title="Favourite"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Favourite action wired to API in future iteration
}}
>
</button>
<a
href={`${cardUrl}?download=1`}
className="nb-card-action-btn"
title="Download"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
</a>
<a
href={cardUrl}
className="nb-card-action-btn"
title="Quick view"
tabIndex={-1}
>
👁
</a>
</div>
</article> </article>
); );
} }

View File

@@ -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;
}

View File

@@ -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 (
<div className={`nb-react-carousel ${atStart ? 'at-start' : ''} ${atEnd ? 'at-end' : ''} ${className}`.trim()}>
<div className="nb-react-fade nb-react-fade--left" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--left"
aria-label="Previous categories"
onClick={() => moveToPill(-1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd"/></svg>
</button>
<div className="nb-react-viewport" ref={viewportRef} role="list" aria-label={ariaLabel}>
<div
ref={stripRef}
className={`nb-react-strip ${dragging ? 'is-dragging' : ''}`}
style={{ transform: `translateX(${offset}px)` }}
>
{items.map((item) => (
<a
key={`${item.href}-${item.label}`}
href={item.href}
className={`nb-react-pill ${item.active ? 'nb-react-pill--active' : ''}`}
aria-current={item.active ? 'page' : 'false'}
data-active-pill={item.active ? 'true' : undefined}
draggable={false}
onDragStart={(event) => event.preventDefault()}
>
{item.label}
</a>
))}
</div>
</div>
<div className="nb-react-fade nb-react-fade--right" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--right"
aria-label="Next categories"
onClick={() => moveToPill(1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
</button>
</div>
);
}

View File

@@ -20,22 +20,20 @@
} }
} }
/* Spec §5: 4 columns desktop, scaling up for very wide screens */
@media (min-width: 1024px) { @media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } [data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
} }
@media (min-width: 1600px) { @media (min-width: 1600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); } [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(6, 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(6, minmax(0, 1fr)) !important; }
} }
@media (min-width: 2600px) { @media (min-width: 2200px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); } [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(7, 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(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-grid] > .nova-card { margin: 0 !important; }
@@ -103,7 +101,7 @@
/* Image is positioned absolutely inside the container so it always fills /* Image is positioned absolutely inside the container so it always fills
the capped box (max-height), cropping top/bottom via object-fit: cover. */ the capped box (max-height), cropping top/bottom via object-fit: cover. */
[data-nova-gallery] [data-gallery-grid] .nova-card-media img { [data-nova-gallery] [data-gallery-grid] .nova-card-media > .nova-card-main-image {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
@@ -136,3 +134,66 @@
transform: translateY(0); transform: translateY(0);
transition: opacity 200ms ease-out, transform 200ms ease-out; transition: opacity 200ms ease-out, transform 200ms ease-out;
} }
/* ── Card hover: bottom glow pulse ───────────────────────────────────────── */
.nova-card > a {
will-change: transform, box-shadow;
}
.nova-card:hover > a {
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08),
0 0 20px rgba(224, 122, 33, 0.07);
}
/* ── Quick action buttons ─────────────────────────────────────────────────── */
/*
* .nb-card-actions absolutely positioned at top-right of .nova-card.
* Fades in + slides down slightly when the card is hovered.
* Requires .nova-card to have position:relative (set inline by ArtworkCard.jsx).
*/
.nb-card-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 30;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transform: translateY(-4px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
pointer-events: none;
}
.nova-card:hover .nb-card-actions,
.nova-card:focus-within .nb-card-actions {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.nb-card-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: rgba(10, 14, 20, 0.75);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
text-decoration: none;
transition: background 150ms ease, transform 150ms ease, color 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-card-action-btn:hover {
background: rgba(224, 122, 33, 0.85);
color: #fff;
transform: scale(1.1);
}

View File

@@ -84,6 +84,54 @@ function SkeletonCard() {
return <div className="nova-skeleton-card" aria-hidden="true" />; return <div className="nova-skeleton-card" aria-hidden="true" />;
} }
// ── Ranking API helpers ───────────────────────────────────────────────────
/**
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
* artwork object shape used by ArtworkCard.
*/
function mapRankApiArtwork(item) {
const w = item.dimensions?.width ?? null;
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
avatar_url: item.author?.avatar_url ?? null,
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
slug: item.slug ?? '',
url: webUrl,
width: w,
height: h,
};
}
/**
* Fetch ranked artworks from the ranking API.
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
*/
async function fetchRankApiArtworks(endpoint, rankType) {
try {
const url = new URL(endpoint, window.location.href);
if (rankType) url.searchParams.set('type', rankType);
const res = await fetch(url.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return { artworks: [] };
const json = await res.json();
const items = Array.isArray(json.data) ? json.data : [];
return { artworks: items.map(mapRankApiArtwork) };
} catch {
return { artworks: [] };
}
}
const SKELETON_COUNT = 10; const SKELETON_COUNT = 10;
// ── Main component ──────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────
@@ -97,6 +145,9 @@ const SKELETON_COUNT = 10;
* initialNextCursor string|null First cursor token * initialNextCursor string|null First cursor token
* initialNextPageUrl string|null First "next page" URL (page-based feeds) * initialNextPageUrl string|null First "next page" URL (page-based feeds)
* limit number Items per page (default 40) * limit number Items per page (default 40)
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
*/ */
function MasonryGallery({ function MasonryGallery({
artworks: initialArtworks = [], artworks: initialArtworks = [],
@@ -105,6 +156,8 @@ function MasonryGallery({
initialNextCursor = null, initialNextCursor = null,
initialNextPageUrl = null, initialNextPageUrl = null,
limit = 40, limit = 40,
rankApiEndpoint = null,
rankType = null,
}) { }) {
const [artworks, setArtworks] = useState(initialArtworks); const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor); const [nextCursor, setNextCursor] = useState(initialNextCursor);
@@ -115,6 +168,28 @@ function MasonryGallery({
const gridRef = useRef(null); const gridRef = useRef(null);
const triggerRef = useRef(null); const triggerRef = useRef(null);
// ── Ranking API fallback ───────────────────────────────────────────────
// When the server-side render provides no initial artworks (e.g. cache miss
// or empty page result) and a ranking API endpoint is configured, perform a
// client-side fetch from the ranking API to hydrate the grid.
// Satisfies spec: "Fallback: Latest if ranking missing".
useEffect(() => {
if (initialArtworks.length > 0) return; // SSR artworks already present
if (!rankApiEndpoint) return; // no API endpoint configured
let cancelled = false;
setLoading(true);
fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => {
if (cancelled) return;
if (ranked.length > 0) {
setArtworks(ranked);
setDone(true); // ranking API returns a full list; no further pagination
}
setLoading(false);
});
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Masonry re-layout ────────────────────────────────────────────────── // ── Masonry re-layout ──────────────────────────────────────────────────
const relayout = useCallback(() => { const relayout = useCallback(() => {
const g = gridRef.current; const g = gridRef.current;
@@ -195,6 +270,10 @@ function MasonryGallery({
return () => io.disconnect(); return () => io.disconnect();
}, [done, fetchNext]); }, [done, fetchNext]);
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ───────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────
return ( return (
<section <section
@@ -210,7 +289,7 @@ function MasonryGallery({
<> <>
<div <div
ref={gridRef} ref={gridRef}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5" className={gridClass}
data-gallery-grid data-gallery-grid
> >
{artworks.map((art, idx) => ( {artworks.map((art, idx) => (

View File

@@ -40,6 +40,8 @@ function mountAll() {
initialNextCursor: container.dataset.nextCursor || null, initialNextCursor: container.dataset.nextCursor || null,
initialNextPageUrl: container.dataset.nextPageUrl || null, initialNextPageUrl: container.dataset.nextPageUrl || null,
limit: parseInt(container.dataset.limit || '40', 10), limit: parseInt(container.dataset.limit || '40', 10),
rankApiEndpoint: container.dataset.rankApiEndpoint || null,
rankType: container.dataset.rankType || null,
}; };
createRoot(container).render(<MasonryGallery {...props} />); createRoot(container).render(<MasonryGallery {...props} />);

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import CategoryPillCarousel from './components/gallery/CategoryPillCarousel';
function mountAll() {
document.querySelectorAll('[data-react-pill-carousel]').forEach((container) => {
if (container.dataset.reactMounted) return;
container.dataset.reactMounted = '1';
let items = [];
try {
items = JSON.parse(container.dataset.items || '[]');
} catch {
items = [];
}
createRoot(container).render(
<CategoryPillCarousel
items={items}
ariaLabel={container.dataset.ariaLabel || 'Filter by category'}
className={container.dataset.className || ''}
/>,
);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountAll);
} else {
mountAll();
}

View File

@@ -158,13 +158,6 @@
/> />
</picture> </picture>
<div class="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 class="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>
@if($authorUrl)
<span class="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>
@endif
</div>
<div class="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 class="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 class="truncate text-sm font-semibold text-white">{{ $title }}</div> <div class="truncate text-sm font-semibold text-white">{{ $title }}</div>
<div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80"> <div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">

View File

@@ -0,0 +1,177 @@
{{--
Gallery Filter Slide-over Panel
────────────────────────────────────────────────────────────────────────────
Triggered by: #gallery-filter-panel-toggle (in gallery/index.blade.php)
Controlled by: initGalleryFilterPanel() (in gallery/index.blade.php scripts)
Available Blade variables (all optional, safe to omit):
$sort_options array Current sort options list
$current_sort string Active sort value
--}}
<div
id="gallery-filter-panel"
role="dialog"
aria-modal="true"
aria-label="Gallery filters"
aria-hidden="true"
class="fixed inset-0 z-50 pointer-events-none"
>
{{-- Backdrop --}}
<div
id="gallery-filter-backdrop"
class="absolute inset-0 bg-black/50 backdrop-blur-sm opacity-0 transition-opacity duration-300 ease-out"
aria-hidden="true"
></div>
{{-- Drawer --}}
<div
id="gallery-filter-drawer"
class="absolute right-0 top-0 bottom-0 w-full md:w-[22rem] bg-nova-800 border-l border-white/10 shadow-2xl
translate-x-full transition-transform duration-300 ease-out
flex flex-col overflow-hidden"
>
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10 shrink-0">
<h2 class="text-base font-semibold text-white/90">Filters</h2>
<button
id="gallery-filter-panel-close"
type="button"
class="rounded-lg p-1.5 text-neutral-400 hover:text-white hover:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
aria-label="Close filters"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Scrollable filter body --}}
<div class="flex-1 overflow-y-auto px-5 py-6 space-y-8">
{{-- ── Orientation ─────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Orientation</legend>
<div class="flex flex-wrap gap-2">
@foreach([['any','Any'],['landscape','Landscape 🖥'],['portrait','Portrait 📱']] as [$val, $label])
<label class="nb-filter-choice">
<input
type="radio"
name="orientation"
value="{{ $val }}"
class="sr-only"
{{ (request('orientation', 'any') === $val) ? 'checked' : '' }}
>
<span class="nb-filter-choice-label">{{ $label }}</span>
</label>
@endforeach
</div>
</fieldset>
{{-- ── Resolution ─────────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Resolution</legend>
<div class="flex flex-wrap gap-2">
@foreach([
['any', 'Any'],
['hd', 'HD 1280×720'],
['fhd', 'Full HD 1920×1080'],
['2k', '2K 2560×1440'],
['4k', '4K 3840×2160'],
] as [$val, $label])
<label class="nb-filter-choice">
<input
type="radio"
name="resolution"
value="{{ $val }}"
class="sr-only"
{{ (request('resolution', 'any') === $val) ? 'checked' : '' }}
>
<span class="nb-filter-choice-label">{{ $label }}</span>
</label>
@endforeach
</div>
</fieldset>
{{-- ── Date Range ───────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Date Range</legend>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-neutral-400 mb-1.5" for="fp-date-from">From</label>
<input
type="date"
id="fp-date-from"
name="date_from"
value="{{ request('date_from') }}"
max="{{ date('Y-m-d') }}"
class="nb-filter-input w-full"
/>
</div>
<div>
<label class="block text-xs text-neutral-400 mb-1.5" for="fp-date-to">To</label>
<input
type="date"
id="fp-date-to"
name="date_to"
value="{{ request('date_to') }}"
max="{{ date('Y-m-d') }}"
class="nb-filter-input w-full"
/>
</div>
</div>
</fieldset>
{{-- ── Author ──────────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Author</legend>
<input
type="text"
id="fp-author"
name="author"
value="{{ request('author') }}"
placeholder="Username or display name"
autocomplete="off"
class="nb-filter-input w-full"
/>
</fieldset>
{{-- ── Sort ─────────────────────────────────────────────────────── --}}
@if(!empty($sort_options))
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Sort By</legend>
<div class="flex flex-col gap-2">
@foreach($sort_options as $opt)
<label class="nb-filter-choice nb-filter-choice--block">
<input
type="radio"
name="sort"
value="{{ $opt['value'] }}"
class="sr-only"
{{ ($current_sort ?? 'trending') === $opt['value'] ? 'checked' : '' }}
>
<span class="nb-filter-choice-label w-full text-left">{{ $opt['label'] }}</span>
</label>
@endforeach
</div>
</fieldset>
@endif
</div>
{{-- Footer actions --}}
<div class="shrink-0 flex items-center gap-3 px-5 py-4 border-t border-white/10 bg-nova-900/40">
<button
id="gallery-filter-reset"
type="button"
class="flex-1 rounded-lg border border-white/10 bg-white/5 py-2.5 text-sm text-neutral-300 hover:text-white hover:bg-white/10 transition-colors"
>
Reset
</button>
<button
id="gallery-filter-apply"
type="button"
class="flex-1 rounded-lg bg-accent py-2.5 text-sm font-semibold text-white shadow-sm shadow-accent/30 hover:bg-amber-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
Apply Filters
</button>
</div>
</div>
</div>

View File

@@ -2,7 +2,6 @@
@php @php
use App\Banner; use App\Banner;
$gridV2 = request()->query('grid') === 'v2';
@endphp @endphp
@php @php
@@ -22,125 +21,257 @@
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif @if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif @if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
<meta name="robots" content="index,follow"> <meta name="robots" content="index,follow">
{{-- OpenGraph --}}
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ $page_canonical ?? $seoUrl(1) }}" />
<meta property="og:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
<meta property="og:site_name" content="Skinbase" />
{{-- Twitter card --}}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
<meta name="twitter:description" content="{{ $page_meta_description ?? '' }}" />
@endpush @endpush
@php
// ── Rank API endpoint ────────────────────────────────────────────────────
// Map the active sort alias to the ranking API ?type= parameter.
// Only trending / fresh / top-rated have pre-computed ranking lists.
$rankTypeMap = [
'trending' => 'trending',
'fresh' => 'new_hot',
'top-rated' => 'best',
];
$rankApiType = $rankTypeMap[$current_sort ?? 'trending'] ?? null;
$rankApiEndpoint = null;
if ($rankApiType) {
if (isset($category) && $category && $category->id ?? null) {
$rankApiEndpoint = '/api/rank/category/' . $category->id;
} elseif (isset($contentType) && $contentType && $contentType->slug ?? null) {
$rankApiEndpoint = '/api/rank/type/' . $contentType->slug;
} else {
$rankApiEndpoint = '/api/rank/global';
}
}
@endphp
@section('content') @section('content')
<div class="container-fluid legacy-page"> <div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp @php Banner::ShowResponsiveAd(); @endphp
<div class="pt-0"> <div class="pt-0">
<div class="mx-auto w-full"> <div class="mx-auto w-full">
<div class="relative flex min-h-[calc(100vh-64px)]"> <div class="relative min-h-[calc(100vh-64px)]">
<button <main class="w-full">
id="sidebar-toggle"
type="button"
class="hidden md:inline-flex items-center justify-center h-10 w-10 rounded-lg border border-white/10 bg-white/5 text-white/90 hover:bg-white/10 absolute top-3 z-20"
aria-controls="sidebar"
aria-expanded="true"
aria-label="Toggle sidebar"
style="left:16px;"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm"> {{-- ═══════════════════════════════════════════════════════════════ --}}
<div class="p-4"> {{-- HERO HEADER --}}
<div class="mt-2 text-sm text-neutral-400"> {{-- ═══════════════════════════════════════════════════════════════ --}}
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($mainCategories as $main)
<li>
<a class="flex items-center gap-2 hover:text-white" href="{{ $main->url }}"><span class="opacity-70">📁</span> {{ $main->name }}</a>
</li>
@endforeach
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 pr-2">
@forelse($subcategories as $sub)
@php
$subName = $sub->category_name ?? $sub->name ?? null;
$subUrl = $sub->url ?? ((isset($sub->slug) && isset($contentType)) ? '/' . $contentType->slug . '/' . $sub->slug : null);
$isActive = isset($category) && isset($sub->id) && $category && ((int) $sub->id === (int) $category->id);
@endphp
<li>
@if($subUrl)
<a class="hover:text-white {{ $isActive ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $subUrl }}">{{ $subName }}</a>
@else
<span class="text-neutral-400">{{ $subName }}</span>
@endif
</li>
@empty
<li><span class="text-neutral-500">No subcategories</span></li>
@endforelse
</ul>
</div>
</div>
</aside>
<main class="flex-1">
<div class="relative overflow-hidden nb-hero-radial"> <div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 opacity-35"></div> {{-- Animated gradient overlays --}}
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
<div class="relative px-6 py-8 md:px-10 md:py-10"> <div class="relative px-6 py-10 md:px-10 md:py-14">
<div class="text-sm text-neutral-400">
@if(($gallery_type ?? null) === 'browse') {{-- Breadcrumb --}}
Browse <nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
@elseif(isset($contentType) && $contentType) <a class="hover:text-white transition-colors" href="/browse">Gallery</a>
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a> @if(isset($contentType) && $contentType)
<span class="opacity-40" aria-hidden="true"></span>
<a class="hover:text-white transition-colors" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@endif
@if(($gallery_type ?? null) === 'category') @if(($gallery_type ?? null) === 'category')
@foreach($breadcrumbs as $crumb) @foreach($breadcrumbs as $crumb)
<span class="opacity-50"></span> <span class="opacity-40" aria-hidden="true"></span>
<a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a> <a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach @endforeach
@endif @endif
</nav>
{{-- Glass title panel --}}
<div class="mt-4 py-5">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
{{ $hero_title ?? 'Browse Artworks' }}
</h1>
@if(!empty($hero_description))
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">
{!! $hero_description !!}
</p>
@endif
@if(is_object($artworks) && method_exists($artworks, 'total') && $artworks->total() > 0)
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ number_format($artworks->total()) }} artworks</span>
</div>
@endif @endif
</div> </div>
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $hero_title ?? 'Browse Artworks' }}</h1>
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">{{ $hero_title ?? 'Browse Artworks' }}</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $hero_description ?? '' !!}</p>
</div> </div>
</section>
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div> <div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
{{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- RANKING TABS --}}
{{-- ═══════════════════════════════════════════════════════════════ --}}
@php
$rankingTabs = [
['value' => 'trending', 'label' => 'Trending', 'icon' => '🔥'],
['value' => 'fresh', 'label' => 'New & Hot', 'icon' => '🚀'],
['value' => 'top-rated', 'label' => 'Best', 'icon' => '⭐'],
['value' => 'latest', 'label' => 'Latest', 'icon' => '🕐'],
];
$activeTab = $current_sort ?? 'trending';
@endphp
<div class="sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="px-6 md:px-10">
<div class="flex items-center justify-between gap-4">
{{-- Tab list --}}
<nav class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist" aria-label="Gallery ranking">
@foreach($rankingTabs as $tab)
@php $isActive = $activeTab === $tab['value']; @endphp
<button
role="tab"
aria-selected="{{ $isActive ? 'true' : 'false' }}"
data-rank-tab="{{ $tab['value'] }}"
class="gallery-rank-tab relative flex items-center gap-1.5 whitespace-nowrap px-5 py-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 {{ $isActive ? 'text-white' : 'text-neutral-400 hover:text-white' }}"
>
<span aria-hidden="true">{{ $tab['icon'] }}</span>
{{ $tab['label'] }}
{{-- Active underline indicator --}}
<span class="nb-tab-indicator absolute bottom-0 left-0 right-0 h-0.5 {{ $isActive ? 'bg-accent scale-x-100' : 'bg-transparent scale-x-0' }} transition-transform duration-300 origin-left rounded-full"></span>
</button>
@endforeach
</nav>
{{-- Filters button wired to slide-over panel (Phase 3) --}}
<button
id="gallery-filter-panel-toggle"
type="button"
class="hidden md:flex items-center gap-2 shrink-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="gallery-filter-panel"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
</svg>
Filters
</button>
</div>
</div> </div>
</div> </div>
<section class="px-6 pb-10 pt-8 md:px-10" data-nova-gallery data-gallery-type="{{ $gallery_type ?? 'browse' }}"> {{-- ═══════════════════════════════════════════════════════════════ --}}
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5' }}" data-gallery-grid> {{-- HORIZONTAL CATEGORY FILTER ROW --}}
@forelse ($artworks as $art) {{-- ═══════════════════════════════════════════════════════════════ --}}
<x-artwork-card @php
:art="$art" $filterItems = $subcategories ?? collect();
:loading="$loop->index < 8 ? 'eager' : 'lazy'" $activeFilterId = isset($category) ? ($category->id ?? null) : null;
:fetchpriority="$loop->index === 0 ? 'high' : null" $categoryAllHref = isset($contentType) && $contentType
/> ? url('/' . $contentType->slug)
@empty : url('/browse');
<div class="panel panel-default effect2"> $activeSortSlug = $activeTab !== 'trending' ? $activeTab : null;
<div class="panel-heading"><strong>No Artworks Yet</strong></div> @endphp
<div class="panel-body">
<p>Once uploads arrive they will appear here. Check back soon.</p>
</div>
</div>
@endforelse
</div>
<div class="flex justify-center mt-10" data-gallery-pagination> @if($filterItems->isNotEmpty())
@if ($artworks instanceof \Illuminate\Contracts\Pagination\Paginator || $artworks instanceof \Illuminate\Contracts\Pagination\CursorPaginator) <div class="sticky top-[57px] z-20 bg-nova-900/80 backdrop-blur-md border-b border-white/[0.06]">
{{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : $artworks->links() }} @php
$allHref = $categoryAllHref . ($activeSortSlug ? '?sort=' . $activeSortSlug : '');
$carouselItems = [[
'label' => 'All',
'href' => $allHref,
'active' => !$activeFilterId,
]];
foreach ($filterItems as $sub) {
$subName = $sub->name ?? $sub->category_name ?? null;
$subUrl = $sub->url ?? null;
if (! $subUrl && isset($sub->slug) && isset($contentType) && $contentType) {
$subUrl = url('/' . $contentType->slug . '/' . $sub->slug);
}
if (! $subName || ! $subUrl) {
continue;
}
$sep = str_contains($subUrl, '?') ? '&' : '?';
$subLinkHref = $activeSortSlug ? ($subUrl . $sep . 'sort=' . $activeSortSlug) : $subUrl;
$isActiveSub = $activeFilterId && isset($sub->id) && (int) $sub->id === (int) $activeFilterId;
$carouselItems[] = [
'label' => $subName,
'href' => $subLinkHref,
'active' => $isActiveSub,
];
}
@endphp
<div
data-react-pill-carousel
data-aria-label="Filter by category"
data-items='@json($carouselItems)'
></div>
</div>
@endif @endif
@php
$galleryItems = (is_object($artworks) && method_exists($artworks, 'getCollection'))
? $artworks->getCollection()
: collect($artworks);
$galleryArtworks = $galleryItems->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
])->values();
$galleryNextPageUrl = (is_object($artworks) && method_exists($artworks, 'nextPageUrl'))
? $artworks->nextPageUrl()
: null;
@endphp
<section class="px-6 pb-10 pt-8 md:px-10">
@if($galleryItems->isEmpty())
<div class="rounded-xl border border-white/10 bg-white/5 p-8 text-center text-white/60">
No artworks found yet. Check back soon.
</div> </div>
<div class="hidden" data-gallery-skeleton-template aria-hidden="true"> @else
<x-skeleton.artwork-card /> <div
</div> data-react-masonry-gallery
<div class="hidden mt-8" data-gallery-skeleton></div> data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="{{ $gallery_type ?? 'browse' }}"
@if($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
@if($rankApiEndpoint) data-rank-api-endpoint="{{ $rankApiEndpoint }}" @endif
@if($rankApiType) data-rank-type="{{ $rankApiType }}" @endif
data-limit="24"
class="min-h-32"
></div>
@endif
</section> </section>
{{-- ─── Filter Slide-over Panel ──────────────────────────────────── --}}
@include('gallery._filter_panel')
</main> </main>
</div> </div>
</div> </div>
@@ -148,155 +279,241 @@
</div> </div>
@endsection @endsection
@push('styles') @push('head')
@if(! $gridV2)
<style> <style>
[data-nova-gallery].is-enhanced [data-gallery-grid] { /* ── Hero ─────────────────────────────────────────────────────── */
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) {
/* Fallback for non-enhanced (no-js) galleries: use 5 columns on desktop */
[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)); }
/* High-specificity override for legacy/tailwind classes */
[data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
}
/* Larger desktop screens: 6 columns */
@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; }
}
/* Ensure dashboard gallery shows 5 columns on desktop even when JS hasn't enhanced */
[data-nova-gallery][data-gallery-type="dashboard"] [data-gallery-grid] {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
@media (min-width: 1600px) {
[data-nova-gallery][data-gallery-type="dashboard"] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
/* Keep pagination visible when JS enhances the gallery so users
have a clear navigation control (numeric links for length-aware
paginators, prev/next for cursor paginators). Make it compact. */
[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%; }
}
.nb-hero-fade { .nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%); background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
} }
.nb-hero-gradient {
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
}
@keyframes nb-hero-shimmer {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
/* ── Ranking Tabs ─────────────────────────────────────────────── */
.gallery-rank-tab {
-webkit-tap-highlight-color: transparent;
}
.gallery-rank-tab .nb-tab-indicator {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1), background-color 200ms ease;
}
/* Legacy: keep nb-scrollbar-none working elsewhere in the page */
.nb-scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.nb-scrollbar-none::-webkit-scrollbar { display: none; }
/* ── Gallery grid fade-in on page load / tab change ─────────── */
@keyframes nb-gallery-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
[data-react-masonry-gallery] {
animation: nb-gallery-fade-in 300ms ease-out both;
}
/* ── Filter panel choice pills ───────────────────────────────── */
.nb-filter-choice { display: inline-flex; cursor: pointer; }
.nb-filter-choice--block { display: flex; width: 100%; }
.nb-filter-choice-label {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
color: rgba(214,224,238,0.8);
font-size: 0.8125rem;
font-weight: 500;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
.nb-filter-choice--block .nb-filter-choice-label {
border-radius: 0.6rem;
width: 100%;
}
.nb-filter-choice input:checked ~ .nb-filter-choice-label {
background: #E07A21;
border-color: #E07A21;
color: #fff;
box-shadow: 0 1px 8px rgba(224,122,33,0.35);
}
.nb-filter-choice input:focus-visible ~ .nb-filter-choice-label {
outline: 2px solid rgba(224,122,33,0.6);
outline-offset: 2px;
}
/* Filter date/text inputs */
.nb-filter-input {
appearance: none;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.5rem;
color: rgba(255,255,255,0.85);
font-size: 0.8125rem;
padding: 0.425rem 0.75rem;
transition: border-color 150ms ease;
color-scheme: dark;
}
.nb-filter-input:focus {
outline: none;
border-color: rgba(224,122,33,0.6);
box-shadow: 0 0 0 3px rgba(224,122,33,0.15);
}
</style> </style>
@endif
@endpush @endpush
@push('scripts') @push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@vite('resources/js/entry-pill-carousel.jsx')
<script src="/js/legacy-gallery-init.js" defer></script> <script src="/js/legacy-gallery-init.js" defer></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { (function () {
var toggle = document.getElementById('sidebar-toggle'); 'use strict';
var sidebar = document.getElementById('sidebar');
if (!toggle || !sidebar) return;
var collapsed = false; // ── Filter Slide-over Panel ──────────────────────────────────────────
try { function initGalleryFilterPanel() {
collapsed = window.localStorage.getItem('gallery.sidebar.collapsed') === '1'; var panel = document.getElementById('gallery-filter-panel');
} catch (e) { var backdrop = document.getElementById('gallery-filter-backdrop');
collapsed = false; var drawer = document.getElementById('gallery-filter-drawer');
var toggleBtn = document.getElementById('gallery-filter-panel-toggle');
var closeBtn = document.getElementById('gallery-filter-panel-close');
var applyBtn = document.getElementById('gallery-filter-apply');
var resetBtn = document.getElementById('gallery-filter-reset');
if (!panel || !drawer || !backdrop) return;
var isOpen = false;
function openPanel() {
isOpen = true;
panel.setAttribute('aria-hidden', 'false');
panel.classList.remove('pointer-events-none');
panel.classList.add('pointer-events-auto');
backdrop.classList.remove('opacity-0');
backdrop.classList.add('opacity-100');
drawer.classList.remove('translate-x-full');
drawer.classList.add('translate-x-0');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'true');
// Focus first interactive element in drawer
var first = drawer.querySelector('button, input, select, a[href]');
if (first) { setTimeout(function () { if (first) first.focus(); }, 320); }
} }
function applySidebarState() { function closePanel() {
if (collapsed) { isOpen = false;
sidebar.classList.add('md:hidden'); panel.setAttribute('aria-hidden', 'true');
toggle.setAttribute('aria-expanded', 'false'); panel.classList.add('pointer-events-none');
panel.classList.remove('pointer-events-auto');
backdrop.classList.add('opacity-0');
backdrop.classList.remove('opacity-100');
drawer.classList.add('translate-x-full');
drawer.classList.remove('translate-x-0');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
}
if (toggleBtn) toggleBtn.addEventListener('click', function () { isOpen ? closePanel() : openPanel(); });
if (closeBtn) closeBtn.addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
// Close on ESC
document.addEventListener('keydown', function (e) {
if (isOpen && (e.key === 'Escape' || e.key === 'Esc')) { closePanel(); }
});
// Apply: collect all named inputs and navigate with updated params
if (applyBtn) {
applyBtn.addEventListener('click', function () {
var url = new URL(window.location.href);
url.searchParams.delete('page');
// Radio groups: orientation, resolution, sort
drawer.querySelectorAll('input[type="radio"]:checked').forEach(function (input) {
if ((input.name === 'orientation' || input.name === 'resolution') && input.value !== 'any') {
url.searchParams.set(input.name, input.value);
} else if (input.name === 'orientation' || input.name === 'resolution') {
url.searchParams.delete(input.name);
} else { } else {
sidebar.classList.remove('md:hidden'); url.searchParams.set(input.name, input.value);
toggle.setAttribute('aria-expanded', 'true');
}
positionToggle();
}
toggle.addEventListener('click', function () {
collapsed = !collapsed;
applySidebarState();
try {
window.localStorage.setItem('gallery.sidebar.collapsed', collapsed ? '1' : '0');
} catch (e) {
// no-op
} }
}); });
function positionToggle() { // Text inputs: author
if (!toggle || !sidebar) return; ['date_from', 'date_to', 'author'].forEach(function (name) {
// when sidebar is visible, position toggle just outside its right edge var el = drawer.querySelector('[name="' + name + '"]');
if (!collapsed) { if (el && el.value) {
var rect = sidebar.getBoundingClientRect(); url.searchParams.set(name, el.value);
if (rect && rect.right) {
toggle.style.left = (rect.right + 8) + 'px';
toggle.style.transform = '';
} else { } else {
// fallback to sidebar width (18rem) url.searchParams.delete(name);
toggle.style.left = 'calc(18rem + 8px)';
} }
} else {
// when collapsed, position toggle near page left edge
toggle.style.left = '16px';
toggle.style.transform = '';
}
}
window.addEventListener('resize', function () { positionToggle(); });
applySidebarState();
// ensure initial position set
positionToggle();
}); });
window.location.href = url.toString();
});
}
// Reset: strip all filter params, keep only current path
if (resetBtn) {
resetBtn.addEventListener('click', function () {
var url = new URL(window.location.href);
['orientation', 'resolution', 'author', 'date_from', 'date_to', 'sort', 'page'].forEach(function (p) {
url.searchParams.delete(p);
});
window.location.href = url.toString();
});
}
}
// ── Ranking Tab navigation ───────────────────────────────────────────
// Clicking a tab updates ?sort= in the URL and navigates.
// Active underline animation plays before navigation for visual feedback.
function initRankingTabs() {
var tabBar = document.getElementById('gallery-ranking-tabs');
if (!tabBar) return;
tabBar.addEventListener('click', function (e) {
var btn = e.target.closest('[data-rank-tab]');
if (!btn) return;
var sortValue = btn.dataset.rankTab;
if (!sortValue) return;
// Optimistic visual feedback — light up the clicked tab
tabBar.querySelectorAll('[data-rank-tab]').forEach(function (t) {
var ind = t.querySelector('.nb-tab-indicator');
if (t === btn) {
t.classList.add('text-white');
t.classList.remove('text-neutral-400');
if (ind) { ind.classList.add('bg-accent', 'scale-x-100'); ind.classList.remove('bg-transparent', 'scale-x-0'); }
} else {
t.classList.remove('text-white');
t.classList.add('text-neutral-400');
if (ind) { ind.classList.remove('bg-accent', 'scale-x-100'); ind.classList.add('bg-transparent', 'scale-x-0'); }
}
});
// Navigate to the new URL
var url = new URL(window.location.href);
url.searchParams.set('sort', sortValue);
url.searchParams.delete('page');
window.location.href = url.toString();
});
}
function init() {
initGalleryFilterPanel();
initRankingTabs();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
</script> </script>
@endpush @endpush

View File

@@ -49,6 +49,7 @@
'thumb_srcset' => $art->thumb_srcset ?? null, 'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '', 'uname' => $art->uname ?? '',
'username' => $art->uname ?? '', 'username' => $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '', 'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '', 'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '', 'slug' => $art->slug ?? '',

View File

@@ -56,6 +56,7 @@
'thumb_srcset' => $art->thumb_srcset ?? null, 'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '', 'uname' => $art->uname ?? '',
'username' => $art->uname ?? '', 'username' => $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '', 'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '', 'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '', 'slug' => $art->slug ?? '',

View File

@@ -21,6 +21,23 @@ Route::middleware(['web', 'throttle:10,1'])
->whereNumber('id') ->whereNumber('id')
->name('api.art.download'); ->name('api.art.download');
// ── Ranking lists (public, throttled, Redis-cached) ─────────────────────────
// GET /api/rank/global?type=trending|new_hot|best
// GET /api/rank/category/{id}?type=trending|new_hot|best
// GET /api/rank/type/{contentType}?type=trending|new_hot|best
Route::prefix('rank')->name('api.rank.')->middleware(['throttle:60,1'])->group(function () {
Route::get('global', [\App\Http\Controllers\Api\RankController::class, 'global'])
->name('global');
Route::get('category/{id}', [\App\Http\Controllers\Api\RankController::class, 'byCategory'])
->whereNumber('id')
->name('category');
Route::get('type/{contentType}', [\App\Http\Controllers\Api\RankController::class, 'byContentType'])
->where('contentType', '[a-z0-9\-]+')
->name('content_type');
});
/** /**
* API v1 routes for Artworks module * API v1 routes for Artworks module
* *

23
scripts/check_stats.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$id = 69478;
$artwork = DB::table('artworks')->where('id', $id)->first();
echo "Artwork: " . ($artwork ? $artwork->title : 'NOT FOUND') . PHP_EOL;
$stats = DB::table('artwork_stats')->where('artwork_id', $id)->first();
echo "artwork_stats row: " . json_encode($stats) . PHP_EOL;
$viewEvents = DB::table('artwork_view_events')->where('artwork_id', $id)->count();
echo "artwork_view_events count: " . $viewEvents . PHP_EOL;
// Check a few artworks that DO have stats
$sample = DB::table('artwork_stats')->whereColumn('views', '>', 'id')->limit(3)->pluck('artwork_id');
echo "Sample artworks with views > 0: " . json_encode($sample) . PHP_EOL;
// Count how many artworks_stats rows exist at all
$total = DB::table('artwork_stats')->count();
echo "Total artwork_stats rows: " . $total . PHP_EOL;

35
scripts/check_stats2.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = require_once __DIR__ . '/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
$artworkId = 69478;
$stats = DB::table('artwork_stats')->where('artwork_id', $artworkId)->first();
echo "artwork_stats row: " . json_encode($stats) . PHP_EOL;
$events = DB::table('artwork_view_events')->where('artwork_id', $artworkId)->count();
echo "artwork_view_events for {$artworkId}: {$events}" . PHP_EOL;
$latest = DB::table('artwork_view_events')->latest('viewed_at')->take(5)->get(['artwork_id', 'viewed_at', 'session_hash']);
echo "Latest view events (any artwork): " . json_encode($latest) . PHP_EOL;
// Check Redis queue depth
try {
$queueLen = Redis::llen('artwork_stats:deltas');
echo "Redis artwork_stats:deltas queue length: {$queueLen}" . PHP_EOL;
if ($queueLen > 0) {
$peek = Redis::lrange('artwork_stats:deltas', 0, 2);
echo "First entries: " . json_encode($peek) . PHP_EOL;
}
} catch (\Exception $e) {
echo "Redis error: " . $e->getMessage() . PHP_EOL;
}
// Check artwork exists
$artwork = DB::table('artworks')->where('id', $artworkId)->first(['id', 'title', 'status', 'user_id']);
echo "Artwork: " . json_encode($artwork) . PHP_EOL;

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Ranking;
use App\Models\Artwork;
use App\Models\RankList;
use App\Models\User;
use App\Services\RankingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Tests for the ranking system API endpoints and service logic.
*
* Covered:
* 1. GET /api/rank/global returns artworks in the pre-ranked order.
* 2. RankingService::applyDiversity() enforces max-per-author cap.
* 3. Fallback to latest when no rank_list row exists for a scope.
*/
class RankGlobalTrendingTest extends TestCase
{
use RefreshDatabase;
// ── Test 1: ranked order ───────────────────────────────────────────────
/**
* A stored rank_list drives the response order.
* The controller must return artworks in the same sequence as artwork_ids.
*/
public function test_global_trending_returns_artworks_in_ranked_order(): void
{
$user = User::factory()->create();
// Create three artworks in chronological order (oldest first)
$oldest = Artwork::factory()->for($user)->create([
'published_at' => now()->subDays(3),
]);
$middle = Artwork::factory()->for($user)->create([
'published_at' => now()->subDays(2),
]);
$newest = Artwork::factory()->for($user)->create([
'published_at' => now()->subDays(1),
]);
// The rank list ranks them in a non-chronological order to prove it is respected:
// newest > oldest > middle
$rankedOrder = [$newest->id, $oldest->id, $middle->id];
RankList::create([
'scope_type' => 'global',
'scope_id' => 0,
'list_type' => 'trending',
'model_version' => 'rank_v1',
'artwork_ids' => $rankedOrder,
'computed_at' => now(),
]);
$response = $this->getJson('/api/rank/global?type=trending');
$response->assertOk();
$returnedIds = collect($response->json('data'))
->pluck('slug')
->all();
// Retrieve slugs in the expected order for comparison
$expectedSlugs = Artwork::whereIn('id', $rankedOrder)
->get()
->keyBy('id')
->pipe(fn ($keyed) => array_map(fn ($id) => $keyed[$id]->slug, $rankedOrder));
$this->assertSame($expectedSlugs, $returnedIds,
'Artworks must be returned in the exact pre-ranked order.'
);
// Meta block is present
$response->assertJsonPath('meta.list_type', 'trending');
$response->assertJsonPath('meta.fallback', false);
$response->assertJsonPath('meta.model_version', 'rank_v1');
}
// ── Test 2: diversity constraint ───────────────────────────────────────
/**
* applyDiversity() must cap the number of artworks per author
* at config('ranking.diversity.max_per_author') = 3.
*
* Given 5 artworks from the same author, only 3 should pass through.
*/
public function test_diversity_constraint_caps_items_per_author(): void
{
/** @var RankingService $service */
$service = app(RankingService::class);
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
$listSize = 50;
// Build fake candidates: 5 from author 1, 3 from author 2
$candidates = [];
for ($i = 1; $i <= 5; $i++) {
$candidates[] = (object) ['artwork_id' => $i, 'user_id' => 1];
}
for ($i = 6; $i <= 8; $i++) {
$candidates[] = (object) ['artwork_id' => $i, 'user_id' => 2];
}
$result = $service->applyDiversity($candidates, $maxPerAuthor, $listSize);
$authorTotals = array_count_values(
array_map(fn ($item) => (int) $item->user_id, $result)
);
foreach ($authorTotals as $authorId => $count) {
$this->assertLessThanOrEqual(
$maxPerAuthor,
$count,
"Author {$authorId} appears {$count} times, but max is {$maxPerAuthor}."
);
}
// Exactly 3 from author 1 + 3 from author 2 = 6 total
$this->assertCount(6, $result);
}
// ── Test 3: fallback to latest ─────────────────────────────────────────
/**
* When no rank_list row exists for the requested scope, the controller
* falls back to latest-published artworks and signals this in the meta.
*/
public function test_global_trending_falls_back_to_latest_when_no_rank_list_exists(): void
{
$user = User::factory()->create();
// Create artworks in a known order so we can verify fallback ordering
$first = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(3)]);
$second = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(2)]);
$third = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(1)]);
// Deliberately leave rank_lists table empty
$response = $this->getJson('/api/rank/global?type=trending');
$response->assertOk();
// Meta must indicate fallback
$response->assertJsonPath('meta.fallback', true);
$response->assertJsonPath('meta.model_version', 'fallback');
// Artworks must appear in published_at DESC order (third, second, first)
$returnedIds = collect($response->json('data'))->pluck('slug')->all();
$this->assertSame(
[$third->slug, $second->slug, $first->slug],
$returnedIds,
'Fallback must return artworks in latest-first order.'
);
}
}

View File

@@ -13,6 +13,7 @@ export default defineConfig({
'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/entry-masonry-gallery.jsx',
'resources/js/entry-pill-carousel.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',