feat: add reusable gallery carousel and ranking feed infrastructure

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

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
/**
* Configure the Meilisearch artworks index:
* sortable attributes (all fields used in category/discover sorts)
* filterable attributes (used in search filters)
*
* Run after any schema / toSearchableArray change:
* php artisan meilisearch:configure-index
*/
class ConfigureMeilisearchIndex extends Command
{
protected $signature = 'meilisearch:configure-index {--index=artworks : Meilisearch index name}';
protected $description = 'Push sortable and filterable attribute settings to the Meilisearch artworks index.';
/**
* Fields that can be used as sort targets in Artwork::search()->options(['sort' => ]).
* Must match keys in Artwork::toSearchableArray().
*/
private const SORTABLE_ATTRIBUTES = [
'created_at',
'trending_score_24h',
'trending_score_7d',
'favorites_count',
'downloads_count',
'awards_received_count',
'views',
'likes',
'downloads',
];
/**
* Fields used in filter expressions (AND category = "" etc.).
*/
private const FILTERABLE_ATTRIBUTES = [
'is_public',
'is_approved',
'category',
'content_type',
'tags',
'author_id',
'orientation',
'resolution',
];
public function handle(): int
{
$indexName = (string) $this->option('index');
/** @var MeilisearchClient $client */
$client = app(MeilisearchClient::class);
$index = $client->index($indexName);
$this->info("Configuring Meilisearch index: {$indexName}");
// ── Sortable attributes ───────────────────────────────────────────────
$this->line(' → Updating sortableAttributes…');
$task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
$this->line(" Task uid: {$task['taskUid']}");
// ── Filterable attributes ─────────────────────────────────────────────
$this->line(' → Updating filterableAttributes…');
$task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES);
$this->line(" Task uid: {$task2['taskUid']}");
$this->info('Done. Meilisearch will process these tasks asynchronously.');
$this->warn('Re-index artworks if sortable attributes changed: php artisan artworks:search-rebuild');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Copy views and downloads from the legacy `wallz` table into `artwork_stats`.
*
* Uses wallz.id as artwork_id.
* Rows that already exist are updated; missing rows are inserted with zeros
* for all other counters.
*
* Usage:
* php artisan skinbase:migrate-wallz-stats
* php artisan skinbase:migrate-wallz-stats --chunk=500 --dry-run
*/
class MigrateWallzStatsCommand extends Command
{
protected $signature = 'skinbase:migrate-wallz-stats
{--chunk=1000 : Number of wallz rows to process per batch}
{--dry-run : Preview counts without writing to the database}';
protected $description = 'Import views and downloads from legacy wallz table into artwork_stats';
public function handle(): int
{
$chunkSize = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No data will be written.');
}
$total = (int) DB::connection('legacy')->table('wallz')->count();
$processed = 0;
$inserted = 0;
$updated = 0;
$this->info("Found {$total} rows in legacy wallz table. Chunk size: {$chunkSize}.");
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% — ins: %message%');
$bar->setMessage('0 ins / 0 upd');
$bar->start();
DB::connection('legacy')
->table('wallz')
->select('id', 'views', 'dls', 'rating', 'rating_num')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($dryRun, &$processed, &$inserted, &$updated, $bar) {
$artworkIds = $rows->pluck('id')->all();
// Find which artwork_ids already have a stats row.
$existing = DB::table('artwork_stats')
->whereIn('artwork_id', $artworkIds)
->pluck('artwork_id')
->flip(); // flip → [artwork_id => index] for O(1) lookup
$toInsert = [];
$now = now()->toDateTimeString();
foreach ($rows as $row) {
$views = max(0, (int) $row->views);
$dls = max(0, (int) $row->dls);
$ratingAvg = max(0, (float) $row->rating);
$ratingCount = max(0, (int) $row->rating_num);
if ($existing->has($row->id)) {
// Update existing row.
if (! $dryRun) {
DB::table('artwork_stats')
->where('artwork_id', $row->id)
->update([
'views' => $views,
'downloads' => $dls,
'rating_avg' => $ratingAvg,
'rating_count' => $ratingCount,
]);
}
$updated++;
} else {
// Batch-collect for insert.
$toInsert[] = [
'artwork_id' => $row->id,
'views' => $views,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => $dls,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => $ratingAvg,
'rating_count' => $ratingCount,
];
$inserted++;
}
}
if (! $dryRun && ! empty($toInsert)) {
DB::table('artwork_stats')->insertOrIgnore($toInsert);
}
$processed += count($rows);
$bar->setMessage("{$inserted} ins / {$updated} upd");
$bar->advance(count($rows));
});
$bar->finish();
$this->newLine();
if ($dryRun) {
$this->warn("DRY RUN complete — would insert {$inserted}, update {$updated} ({$processed} rows scanned).");
} else {
$this->info("Done — inserted {$inserted}, updated {$updated} ({$processed} rows processed).");
}
return self::SUCCESS;
}
}