feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop

This commit is contained in:
2026-02-25 19:11:23 +01:00
parent 5c97488e80
commit 0032aec02f
131 changed files with 15674 additions and 597 deletions

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Services\ArtworkAwardService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`.
*
* Score mapping (legacy score new medal):
* 4 gold (weight 3)
* 3 silver (weight 2)
* 2 bronze (weight 1)
* 1 skipped (too low to map meaningfully)
*
* Usage:
* php artisan awards:import-legacy
* php artisan awards:import-legacy --dry-run
* php artisan awards:import-legacy --chunk=500
* php artisan awards:import-legacy --skip-stats (skip final stats recalc)
*/
class ImportLegacyAwards extends Command
{
protected $signature = 'awards:import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=250 : Rows to process per batch}
{--skip-stats : Skip per-artwork stats recalculation at the end}
{--force : Overwrite existing awards instead of skipping duplicates}';
protected $description = 'Import legacy users_opinions into artwork_awards';
/** Maps legacy score value → medal string */
private const SCORE_MAP = [
4 => 'gold',
3 => 'silver',
2 => 'bronze',
];
public function handle(ArtworkAwardService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipStats = (bool) $this->option('skip-stats');
$force = (bool) $this->option('force');
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// Verify legacy connection is reachable
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
$this->error('Legacy table `users_opinions` not found.');
return self::FAILURE;
}
// Pre-load sets of valid artwork IDs and user IDs from the new DB
$this->info('Loading new-DB artwork and user ID sets…');
$validArtworkIds = DB::table('artworks')
->whereNull('deleted_at')
->pluck('id')
->flip() // flip so we can use isset() for O(1) lookup
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$this->info(sprintf(
'Found %d artworks and %d users in new DB.',
count($validArtworkIds),
count($validUserIds)
));
// Count legacy rows for progress bar
$total = DB::connection('legacy')
->table('users_opinions')
->count();
$this->info("Legacy rows to process: {$total}");
if ($total === 0) {
$this->warn('No legacy rows found. Nothing to do.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_score' => 0,
'skipped_artwork' => 0,
'skipped_user' => 0,
'skipped_duplicate'=> 0,
'updated_force' => 0,
'errors' => 0,
];
$affectedArtworkIds = [];
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
$bar->setMessage('0', 'imported');
$bar->setMessage('0', 'skipped');
$bar->start();
DB::connection('legacy')
->table('users_opinions')
->orderBy('opinion_id')
->chunk($chunk, function ($rows) use (
&$stats,
&$affectedArtworkIds,
$validArtworkIds,
$validUserIds,
$dryRun,
$force,
$bar
) {
$inserts = [];
$now = now();
foreach ($rows as $row) {
$artworkId = (int) $row->artwork_id;
$userId = (int) $row->author_id; // author_id = the voter
$score = (int) $row->score;
$postedAt = $row->post_date ?? $now;
// --- score → medal ---
$medal = self::SCORE_MAP[$score] ?? null;
if ($medal === null) {
$stats['skipped_score']++;
$bar->advance();
continue;
}
// --- Artwork must exist in new DB ---
if (! isset($validArtworkIds[$artworkId])) {
$stats['skipped_artwork']++;
$bar->advance();
continue;
}
// --- User must exist in new DB ---
if (! isset($validUserIds[$userId])) {
$stats['skipped_user']++;
$bar->advance();
continue;
}
if (! $dryRun) {
if ($force) {
// Upsert: update medal if row already exists
$affected = DB::table('artwork_awards')
->where('artwork_id', $artworkId)
->where('user_id', $userId)
->update([
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
'updated_at' => $now,
]);
if ($affected > 0) {
$stats['updated_force']++;
$affectedArtworkIds[$artworkId] = true;
$bar->advance();
continue;
}
} else {
// Skip if already exists
if (
DB::table('artwork_awards')
->where('artwork_id', $artworkId)
->where('user_id', $userId)
->exists()
) {
$stats['skipped_duplicate']++;
$bar->advance();
continue;
}
}
$inserts[] = [
'artwork_id' => $artworkId,
'user_id' => $userId,
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
'created_at' => $postedAt,
'updated_at' => $postedAt,
];
$affectedArtworkIds[$artworkId] = true;
}
$stats['imported']++;
$bar->advance();
}
// Bulk insert the batch (DB::table bypasses the observer intentionally;
// stats are recalculated in bulk at the end for performance)
if (! $dryRun && ! empty($inserts)) {
try {
DB::table('artwork_awards')->insert($inserts);
} catch (\Throwable $e) {
// Fallback: insert one-by-one to isolate constraint violations
foreach ($inserts as $row) {
try {
DB::table('artwork_awards')->insertOrIgnore([$row]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
$skippedTotal = $stats['skipped_score']
+ $stats['skipped_artwork']
+ $stats['skipped_user']
+ $stats['skipped_duplicate'];
$bar->setMessage((string) $stats['imported'], 'imported');
$bar->setMessage((string) $skippedTotal, 'skipped');
});
$bar->finish();
$this->newLine(2);
// -------------------------------------------------------------------------
// Recalculate stats for every affected artwork
// -------------------------------------------------------------------------
if (! $dryRun && ! $skipStats && ! empty($affectedArtworkIds)) {
$artworkCount = count($affectedArtworkIds);
$this->info("Recalculating award stats for {$artworkCount} artworks…");
$statsBar = $this->output->createProgressBar($artworkCount);
$statsBar->start();
foreach (array_keys($affectedArtworkIds) as $artworkId) {
try {
$service->recalcStats($artworkId);
} catch (\Throwable $e) {
$this->newLine();
$this->warn("Stats recalc failed for artwork #{$artworkId}: {$e->getMessage()}");
}
$statsBar->advance();
}
$statsBar->finish();
$this->newLine(2);
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
$this->table(
['Result', 'Count'],
[
['Imported (new rows)', $stats['imported']],
['Forced updates', $stats['updated_force']],
['Skipped bad score', $stats['skipped_score']],
['Skipped artwork gone', $stats['skipped_artwork']],
['Skipped user gone', $stats['skipped_user']],
['Skipped duplicate', $stats['skipped_duplicate']],
['Errors', $stats['errors']],
]
);
if ($dryRun) {
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
} else {
$this->info('Migration complete.');
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
}