feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
79
app/Console/Commands/ConfigureMeilisearchIndex.php
Normal file
79
app/Console/Commands/ConfigureMeilisearchIndex.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Console/Commands/MigrateWallzStatsCommand.php
Normal file
123
app/Console/Commands/MigrateWallzStatsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
144
app/Http/Controllers/Api/RankController.php
Normal file
144
app/Http/Controllers/Api/RankController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,34 +78,43 @@ 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(
|
||||||
'filter' => 'is_public = true AND is_approved = true',
|
"browse.all.{$sort}.{$page}",
|
||||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
$ttl,
|
||||||
])->paginate($perPage);
|
fn () => Artwork::search('')->options([
|
||||||
|
'filter' => 'is_public = true AND is_approved = true',
|
||||||
|
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||||
|
])->paginate($perPage)
|
||||||
|
);
|
||||||
|
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||||
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
|
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
|
||||||
|
|
||||||
$mainCategories = $this->mainCategories();
|
$mainCategories = $this->mainCategories();
|
||||||
|
|
||||||
return view('gallery.index', [
|
return view('gallery.index', [
|
||||||
'gallery_type' => 'browse',
|
'gallery_type' => 'browse',
|
||||||
'mainCategories' => $mainCategories,
|
'mainCategories' => $mainCategories,
|
||||||
'subcategories' => $mainCategories,
|
'subcategories' => $mainCategories,
|
||||||
'contentType' => null,
|
'contentType' => null,
|
||||||
'category' => null,
|
'category' => null,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'hero_title' => 'Browse Artworks',
|
'current_sort' => $sort,
|
||||||
|
'sort_options' => self::SORT_OPTIONS,
|
||||||
|
'hero_title' => 'Browse Artworks',
|
||||||
'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.',
|
'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.',
|
||||||
'breadcrumbs' => collect(),
|
'breadcrumbs' => collect(),
|
||||||
'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase',
|
'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase',
|
||||||
'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.",
|
'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.",
|
||||||
'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo',
|
'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo',
|
||||||
'page_canonical' => $seo['canonical'],
|
'page_canonical' => $seo['canonical'],
|
||||||
'page_rel_prev' => $seo['prev'],
|
'page_rel_prev' => $seo['prev'],
|
||||||
'page_rel_next' => $seo['next'],
|
'page_rel_next' => $seo['next'],
|
||||||
'page_robots' => 'index,follow',
|
'page_robots' => 'index,follow',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,37 +130,47 @@ 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(
|
||||||
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
"gallery.ct.{$contentSlug}.{$sort}.{$page}",
|
||||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
$ttl,
|
||||||
])->paginate($perPage);
|
fn () => Artwork::search('')->options([
|
||||||
|
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
||||||
|
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||||
|
])->paginate($perPage)
|
||||||
|
);
|
||||||
|
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
|
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
|
||||||
|
|
||||||
return view('gallery.index', [
|
return view('gallery.index', [
|
||||||
'gallery_type' => 'content-type',
|
'gallery_type' => 'content-type',
|
||||||
'mainCategories' => $mainCategories,
|
'mainCategories' => $mainCategories,
|
||||||
'subcategories' => $rootCategories,
|
'subcategories' => $rootCategories,
|
||||||
'contentType' => $contentType,
|
'contentType' => $contentType,
|
||||||
'category' => null,
|
'category' => null,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'hero_title' => $contentType->name,
|
'current_sort' => $sort,
|
||||||
|
'sort_options' => self::SORT_OPTIONS,
|
||||||
|
'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'],
|
||||||
'page_rel_next' => $seo['next'],
|
'page_rel_next' => $seo['next'],
|
||||||
'page_robots' => 'index,follow',
|
'page_robots' => 'index,follow',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -134,22 +206,24 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
return view('gallery.index', [
|
return view('gallery.index', [
|
||||||
'gallery_type' => 'category',
|
'gallery_type' => 'category',
|
||||||
'mainCategories' => $mainCategories,
|
'mainCategories' => $mainCategories,
|
||||||
'subcategories' => $subcategories,
|
'subcategories' => $subcategories,
|
||||||
'contentType' => $contentType,
|
'contentType' => $contentType,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'hero_title' => $category->name,
|
'current_sort' => $sort,
|
||||||
|
'sort_options' => self::SORT_OPTIONS,
|
||||||
|
'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'],
|
||||||
'page_rel_next' => $seo['next'],
|
'page_rel_next' => $seo['next'],
|
||||||
'page_robots' => 'index,follow',
|
'page_robots' => 'index,follow',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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) {
|
||||||
'id' => $item['id'] ?? 0,
|
$width = isset($item['width']) && $item['width'] > 0 ? (int) $item['width'] : null;
|
||||||
'name' => $item['title'] ?? 'Untitled',
|
$height = isset($item['height']) && $item['height'] > 0 ? (int) $item['height'] : null;
|
||||||
'category_name' => '',
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($item['author_id'] ?? 0), null, 64);
|
||||||
'thumb_url' => $item['thumbnail_url'] ?? null,
|
|
||||||
'thumb_srcset' => $item['thumbnail_url'] ?? null,
|
return (object) [
|
||||||
'uname' => $item['author'] ?? 'Artist',
|
'id' => $item['id'] ?? 0,
|
||||||
'published_at' => null,
|
'name' => $item['title'] ?? 'Untitled',
|
||||||
'slug' => $item['slug'] ?? '',
|
'category_name' => $item['category_name'] ?? '',
|
||||||
]);
|
'category_slug' => $item['category_slug'] ?? '',
|
||||||
|
'thumb_url' => $item['thumbnail_url'] ?? null,
|
||||||
|
'thumb_srcset' => $item['thumbnail_url'] ?? null,
|
||||||
|
'uname' => $item['author'] ?? 'Artist',
|
||||||
|
'username' => $item['username'] ?? '',
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'published_at' => $item['published_at'] ?? null,
|
||||||
|
'slug' => $item['slug'] ?? '',
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
$meta = $feedResult['meta'] ?? [];
|
$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,
|
||||||
|
|||||||
206
app/Jobs/RankBuildListsJob.php
Normal file
206
app/Jobs/RankBuildListsJob.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Jobs/RankComputeArtworkScoresJob.php
Normal file
79
app/Jobs/RankComputeArtworkScoresJob.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Models/RankArtworkScore.php
Normal file
69
app/Models/RankArtworkScore.php
Normal 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
53
app/Models/RankList.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
342
app/Services/RankingService.php
Normal file
342
app/Services/RankingService.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
63
config/ranking.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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, '');
|
||||||
}
|
}
|
||||||
@@ -15,17 +9,8 @@ function slugify(str) {
|
|||||||
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
|
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
|
||||||
*/
|
*/
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
158
resources/js/components/gallery/CategoryPillCarousel.css
Normal file
158
resources/js/components/gallery/CategoryPillCarousel.css
Normal 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;
|
||||||
|
}
|
||||||
282
resources/js/components/gallery/CategoryPillCarousel.jsx
Normal file
282
resources/js/components/gallery/CategoryPillCarousel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
31
resources/js/entry-pill-carousel.jsx
Normal file
31
resources/js/entry-pill-carousel.jsx
Normal 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();
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
177
resources/views/gallery/_filter_panel.blade.php
Normal file
177
resources/views/gallery/_filter_panel.blade.php
Normal 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>
|
||||||
@@ -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)
|
||||||
@if(($gallery_type ?? null) === 'category')
|
<span class="opacity-40" aria-hidden="true">›</span>
|
||||||
@foreach($breadcrumbs as $crumb)
|
<a class="hover:text-white transition-colors" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
|
||||||
<span class="opacity-50">›</span>
|
@endif
|
||||||
<a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
|
@if(($gallery_type ?? null) === 'category')
|
||||||
@endforeach
|
@foreach($breadcrumbs as $crumb)
|
||||||
@endif
|
<span class="opacity-40" aria-hidden="true">›</span>
|
||||||
|
<a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
|
||||||
|
@endforeach
|
||||||
|
@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>
|
</div>
|
||||||
|
|
||||||
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
|
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
|
||||||
<div class="p-5 md:p-6">
|
</div>
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></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
|
||||||
@endif
|
$allHref = $categoryAllHref . ($activeSortSlug ? '?sort=' . $activeSortSlug : '');
|
||||||
</div>
|
$carouselItems = [[
|
||||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
'label' => 'All',
|
||||||
<x-skeleton.artwork-card />
|
'href' => $allHref,
|
||||||
</div>
|
'active' => !$activeFilterId,
|
||||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
]];
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@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>
|
||||||
|
@else
|
||||||
|
<div
|
||||||
|
data-react-masonry-gallery
|
||||||
|
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');
|
||||||
} else {
|
panel.classList.remove('pointer-events-auto');
|
||||||
sidebar.classList.remove('md:hidden');
|
backdrop.classList.add('opacity-0');
|
||||||
toggle.setAttribute('aria-expanded', 'true');
|
backdrop.classList.remove('opacity-100');
|
||||||
}
|
drawer.classList.add('translate-x-full');
|
||||||
positionToggle();
|
drawer.classList.remove('translate-x-0');
|
||||||
|
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle.addEventListener('click', function () {
|
if (toggleBtn) toggleBtn.addEventListener('click', function () { isOpen ? closePanel() : openPanel(); });
|
||||||
collapsed = !collapsed;
|
if (closeBtn) closeBtn.addEventListener('click', closePanel);
|
||||||
applySidebarState();
|
backdrop.addEventListener('click', closePanel);
|
||||||
try {
|
|
||||||
window.localStorage.setItem('gallery.sidebar.collapsed', collapsed ? '1' : '0');
|
// Close on ESC
|
||||||
} catch (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
// no-op
|
if (isOpen && (e.key === 'Escape' || e.key === 'Esc')) { closePanel(); }
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function positionToggle() {
|
// Apply: collect all named inputs and navigate with updated params
|
||||||
if (!toggle || !sidebar) return;
|
if (applyBtn) {
|
||||||
// when sidebar is visible, position toggle just outside its right edge
|
applyBtn.addEventListener('click', function () {
|
||||||
if (!collapsed) {
|
var url = new URL(window.location.href);
|
||||||
var rect = sidebar.getBoundingClientRect();
|
url.searchParams.delete('page');
|
||||||
if (rect && rect.right) {
|
|
||||||
toggle.style.left = (rect.right + 8) + 'px';
|
// Radio groups: orientation, resolution, sort
|
||||||
toggle.style.transform = '';
|
drawer.querySelectorAll('input[type="radio"]:checked').forEach(function (input) {
|
||||||
} else {
|
if ((input.name === 'orientation' || input.name === 'resolution') && input.value !== 'any') {
|
||||||
// fallback to sidebar width (18rem)
|
url.searchParams.set(input.name, input.value);
|
||||||
toggle.style.left = 'calc(18rem + 8px)';
|
} else if (input.name === 'orientation' || input.name === 'resolution') {
|
||||||
}
|
url.searchParams.delete(input.name);
|
||||||
} else {
|
} else {
|
||||||
// when collapsed, position toggle near page left edge
|
url.searchParams.set(input.name, input.value);
|
||||||
toggle.style.left = '16px';
|
}
|
||||||
toggle.style.transform = '';
|
});
|
||||||
}
|
|
||||||
|
// Text inputs: author
|
||||||
|
['date_from', 'date_to', 'author'].forEach(function (name) {
|
||||||
|
var el = drawer.querySelector('[name="' + name + '"]');
|
||||||
|
if (el && el.value) {
|
||||||
|
url.searchParams.set(name, el.value);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', function () { positionToggle(); });
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applySidebarState();
|
// ── Ranking Tab navigation ───────────────────────────────────────────
|
||||||
// ensure initial position set
|
// Clicking a tab updates ?sort= in the URL and navigates.
|
||||||
positionToggle();
|
// 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
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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
23
scripts/check_stats.php
Normal 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
35
scripts/check_stats2.php
Normal 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;
|
||||||
161
tests/Feature/Ranking/RankGlobalTrendingTest.php
Normal file
161
tests/Feature/Ranking/RankGlobalTrendingTest.php
Normal 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user