minor fixes

This commit is contained in:
2026-04-09 08:50:36 +02:00
parent 23d363a50c
commit a2457f4e49
75 changed files with 3848 additions and 387 deletions

View File

@@ -78,6 +78,12 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
SKINBASE_UPLOADS_V2=false
# Upload transport tuning
UPLOAD_CHUNK_MAX_BYTES=5242880
UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000
UPLOAD_RATE_CHUNK_USER=180
UPLOAD_RATE_CHUNK_IP=360
# Draft abuse prevention controls
SKINBASE_MAX_DRAFTS=10
SKINBASE_MAX_DRAFT_STORAGE_MB=1024

View File

@@ -26,6 +26,7 @@ class ConfigureMeilisearchIndex extends Command
*/
private const SORTABLE_ATTRIBUTES = [
'created_at',
'published_at_ts',
'trending_score_24h',
'trending_score_7d',
'favorites_count',

View File

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Meilisearch\Client as MeilisearchClient;
use Throwable;
/**
* Comprehensive service health check.
*
* Usage:
* php artisan health:check # all checks
* php artisan health:check --only=meili # single service
* php artisan health:check --json # machine-readable JSON output
*/
class HealthCheckCommand extends Command
{
protected $signature = 'health:check
{--only= : Run only a named check (mysql|redis|cache|meilisearch|reverb|vision|horizon|app)}
{--json : Output results as JSON}';
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Reverb, Vision, Horizon, App).';
/** Collected results: [name => [status, message, details]] */
private array $results = [];
public function handle(): int
{
$only = $this->option('only') ? strtolower((string) $this->option('only')) : null;
$checks = [
'mysql' => fn () => $this->checkMysql(),
'redis' => fn () => $this->checkRedis(),
'cache' => fn () => $this->checkCache(),
'meilisearch' => fn () => $this->checkMeilisearch(),
'reverb' => fn () => $this->checkReverb(),
'vision' => fn () => $this->checkVision(),
'horizon' => fn () => $this->checkHorizon(),
'app' => fn () => $this->checkApp(),
];
if ($only !== null) {
if (! array_key_exists($only, $checks)) {
$this->error("Unknown check '{$only}'. Available: " . implode(', ', array_keys($checks)));
return self::FAILURE;
}
$checks = [$only => $checks[$only]];
}
foreach ($checks as $name => $check) {
$check();
}
if ($this->option('json')) {
$this->line(json_encode($this->results, JSON_PRETTY_PRINT));
return $this->hasFailures() ? self::FAILURE : self::SUCCESS;
}
$this->renderTable();
$failed = $this->countByStatus('fail');
$warned = $this->countByStatus('warn');
$this->newLine();
if ($failed > 0) {
$this->error("{$failed} check(s) FAILED" . ($warned > 0 ? ", {$warned} warning(s)" : '') . '.');
return self::FAILURE;
}
if ($warned > 0) {
$this->warn("⚠️ All checks passed with {$warned} warning(s).");
return self::SUCCESS;
}
$this->info('✅ All checks passed.');
return self::SUCCESS;
}
// ── Individual checks ──────────────────────────────────────────────────────
private function checkMysql(): void
{
try {
DB::select('SELECT 1');
$db = config('database.connections.' . config('database.default') . '.database');
$artworkCount = DB::table('artworks')->whereNull('deleted_at')->count();
$this->pass('mysql', "Connected to `{$db}`. Artworks in DB: {$artworkCount}.", ['artwork_count' => $artworkCount]);
} catch (Throwable $e) {
$this->failCheck('mysql', 'Connection failed: ' . $e->getMessage());
}
}
private function checkRedis(): void
{
try {
$pong = Redis::ping();
// ping returns "+PONG\r\n" string or true depending on driver
$ok = $pong === true || str_contains((string) $pong, 'PONG');
if ($ok) {
$info = Redis::info('server');
$version = $info['redis_version'] ?? ($info['Server']['redis_version'] ?? 'unknown');
$this->pass('redis', "Reachable. Redis version: {$version}.", ['version' => $version]);
} else {
$this->failCheck('redis', 'Unexpected ping response: ' . var_export($pong, true));
}
} catch (Throwable $e) {
$this->failCheck('redis', 'Connection failed: ' . $e->getMessage());
}
}
private function checkCache(): void
{
try {
$key = '_healthcheck_' . uniqid('', true);
$value = 'ok_' . time();
Cache::put($key, $value, 10);
$got = Cache::get($key);
Cache::forget($key);
$driver = config('cache.default');
if ($got === $value) {
$this->pass('cache', "Driver `{$driver}` read/write OK.", ['driver' => $driver]);
} else {
$this->failCheck('cache', "Driver `{$driver}`: wrote '{$value}' but read back " . var_export($got, true));
}
} catch (Throwable $e) {
$this->failCheck('cache', 'Cache test failed: ' . $e->getMessage());
}
}
private function checkMeilisearch(): void
{
try {
/** @var MeilisearchClient $client */
$client = app(MeilisearchClient::class);
$health = $client->health();
if (($health['status'] ?? '') !== 'available') {
$this->failCheck('meilisearch', 'Meilisearch reports unhealthy status: ' . json_encode($health));
return;
}
$version = $client->version()['pkgVersion'] ?? 'unknown';
$indexName = (new Artwork())->searchableAs();
$index = $client->index($indexName);
$stats = $index->stats();
$docCount = (int) ($stats['numberOfDocuments'] ?? 0);
$isIndexing = (bool) ($stats['isIndexing'] ?? false);
// Expected: ≥ 50% of DB artworks should be indexed
$dbCount = DB::table('artworks')
->whereNull('deleted_at')
->where('is_public', 1)
->where('is_approved', 1)
->count();
$status = 'pass';
$message = "v{$version}. Index `{$indexName}`: {$docCount} docs (DB public+approved: {$dbCount}).";
if ($isIndexing) {
$message .= ' [currently re-indexing]';
}
if ($docCount === 0) {
$status = 'fail';
$message = "Index `{$indexName}` is EMPTY (DB has {$dbCount} public+approved artworks). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\"";
} elseif ($dbCount > 0 && $docCount < (int) ($dbCount * 0.5)) {
$status = 'warn';
$message .= " — indexed count is < 50% of DB count. Index may be stale. Run: php artisan artworks:search-rebuild";
}
// Check pending Meilisearch tasks
try {
$tasks = $client->getTasks(['statuses' => 'enqueued,processing']);
$pendingCount = $tasks->getTotal();
if ($pendingCount > 0) {
$message .= " ({$pendingCount} tasks still pending)";
}
} catch (Throwable) {
// non-fatal
}
$this->result('meilisearch', $status, $message, [
'index' => $indexName,
'indexed_docs' => $docCount,
'db_eligible' => $dbCount,
'is_indexing' => $isIndexing,
'meili_version' => $version,
]);
} catch (Throwable $e) {
$this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage());
}
}
private function checkReverb(): void
{
$host = config('reverb.servers.reverb.options.host') ?? env('REVERB_HOST', '');
$port = (int) (config('reverb.servers.reverb.options.port') ?? env('REVERB_PORT', 443));
$scheme = config('reverb.servers.reverb.options.scheme') ?? env('REVERB_SCHEME', 'https');
if (empty($host)) {
$this->warn_check('reverb', 'REVERB_HOST not configured — skipping.');
return;
}
// Reverb exposes an HTTP health endpoint at /apps/{appId}
// We do a plain TCP connect as the minimal check; a refused connection means down.
$timeout = 5;
try {
$errno = 0;
$errstr = '';
$proto = $scheme === 'https' ? 'ssl' : 'tcp';
$fp = @fsockopen("{$proto}://{$host}", $port, $errno, $errstr, $timeout);
if ($fp === false) {
$this->failCheck('reverb', "Cannot connect to {$host}:{$port} ({$scheme}) — {$errstr} [{$errno}]");
return;
}
fclose($fp);
// Try the HTTP health probe (Reverb answers 200 on /)
$url = "{$scheme}://{$host}:{$port}/";
$response = $this->httpGet($url, 3);
$code = $response['code'] ?? 0;
// Reverb returns 200 or 400 on the root — both mean it's alive
if ($code >= 200 && $code < 500) {
$this->pass('reverb', "Reachable at {$host}:{$port} (HTTP {$code}).", ['host' => $host, 'port' => $port]);
} else {
$this->warn_check('reverb', "TCP open but HTTP returned {$code}.", ['host' => $host, 'port' => $port]);
}
} catch (Throwable $e) {
$this->failCheck('reverb', 'Check failed: ' . $e->getMessage());
}
}
private function checkVision(): void
{
$services = [
'CLIP / Gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/') . '/health',
'Vector Gateway' => rtrim((string) config('vision.vector_gateway.base_url', ''), '/') . '/health',
];
$allPassed = true;
$messages = [];
foreach ($services as $label => $url) {
if ($url === '/health' || $url === '') {
$messages[] = "{$label}: not configured";
continue;
}
$response = $this->httpGet($url, 5);
$code = $response['code'] ?? 0;
if ($code >= 200 && $code < 300) {
$messages[] = "{$label}: OK (HTTP {$code})";
} elseif ($code === 0) {
$allPassed = false;
$messages[] = "{$label}: UNREACHABLE ({$url})";
} else {
$allPassed = false;
$messages[] = "{$label}: HTTP {$code} ({$url})";
}
}
$summary = implode(' | ', $messages);
if ($allPassed) {
$this->pass('vision', $summary);
} else {
$this->warn_check('vision', $summary);
}
}
private function checkHorizon(): void
{
try {
// Horizon stores its status in Redis under the horizon:master-supervisor key prefix.
// A simpler cross-version check: look for any horizon-related Redis key.
$status = Cache::store('redis')->get('horizon:status');
if ($status === null) {
// Try reading directly from Redis
$status = Redis::get('horizon:status');
}
if ($status === null) {
$this->warn_check('horizon', 'No Horizon status key in Redis — Horizon may not be running or has never started.');
return;
}
$status = is_string($status) ? strtolower(trim($status)) : strtolower((string) $status);
if ($status === 'running') {
$this->pass('horizon', "Horizon status: running.");
} elseif ($status === 'paused') {
$this->warn_check('horizon', "Horizon is PAUSED. Resume with: php artisan horizon:continue");
} else {
$this->failCheck('horizon', "Horizon status: {$status}. Start with: php artisan horizon");
}
} catch (Throwable $e) {
$this->warn_check('horizon', 'Could not read Horizon status: ' . $e->getMessage());
}
}
private function checkApp(): void
{
$appUrl = rtrim((string) config('app.url', ''), '/');
if (empty($appUrl) || str_contains($appUrl, '.test') || str_contains($appUrl, 'localhost')) {
$this->warn_check('app', "APP_URL is `{$appUrl}` — looks like a local/dev URL, skipping HTTP probe.");
return;
}
// Probe the app homepage
$response = $this->httpGet($appUrl . '/', 10);
$code = $response['code'] ?? 0;
if ($code === 200) {
$ttfb = $response['ttfb'] ?? 0;
$this->pass('app', "Homepage responded HTTP 200. TTFB: {$ttfb}ms.", ['url' => $appUrl, 'ttfb_ms' => $ttfb]);
} elseif ($code > 0) {
$this->warn_check('app', "Homepage returned HTTP {$code}. URL: {$appUrl}", ['url' => $appUrl, 'http_code' => $code]);
} else {
$this->failCheck('app', "Homepage unreachable. URL: {$appUrl}");
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
private function httpGet(string $url, int $timeout = 5): array
{
$start = microtime(true);
try {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json, text/html'],
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ttfb = (int) round((microtime(true) - $start) * 1000);
return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb];
} catch (Throwable) {
return ['code' => 0, 'body' => '', 'ttfb' => 0];
}
}
private function pass(string $name, string $message, array $details = []): void
{
$this->result($name, 'pass', $message, $details);
}
private function failCheck(string $name, string $message, array $details = []): void
{
$this->result($name, 'fail', $message, $details);
}
private function warn_check(string $name, string $message, array $details = []): void
{
$this->result($name, 'warn', $message, $details);
}
private function result(string $name, string $status, string $message, array $details = []): void
{
$this->results[$name] = [
'status' => $status,
'message' => $message,
'details' => $details,
];
}
private function renderTable(): void
{
$this->newLine();
$this->line(' <fg=white;options=bold>SERVICE STATUS MESSAGE</>');
$this->line(' ' . str_repeat('─', 90));
foreach ($this->results as $name => $r) {
[$icon, $color] = match ($r['status']) {
'pass' => ['✅', 'green'],
'warn' => ['⚠️ ', 'yellow'],
default => ['❌', 'red'],
};
$label = str_pad(strtoupper($name), 15);
$status = str_pad(strtoupper($r['status']), 7);
$message = $r['message'];
$this->line(" {$icon} <fg={$color}>{$label} {$status}</> {$message}");
}
$this->line(' ' . str_repeat('─', 90));
}
private function hasFailures(): bool
{
return $this->countByStatus('fail') > 0;
}
private function countByStatus(string $status): int
{
return count(array_filter($this->results, fn ($r) => $r['status'] === $status));
}
}

View File

@@ -13,12 +13,14 @@ use Throwable;
*
* Usage:
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
* php artisan skinbase:import-legacy-artworks --artwork-id=69527
*/
class ImportLegacyArtworks extends Command
{
protected $signature = 'skinbase:import-legacy-artworks
{--chunk=500 : chunk size for processing}
{--limit= : maximum number of legacy rows to import}
{--artwork-id= : import only one legacy wallz row by id}
{--dry-run : do not persist any changes}
{--legacy-connection=legacy : name of legacy DB connection}
{--legacy-table=wallz : legacy artworks table name}
@@ -73,15 +75,28 @@ class ImportLegacyArtworks extends Command
{
$chunk = (int) $this->option('chunk');
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
$artworkId = $this->option('artwork-id') ? (int) $this->option('artwork-id') : null;
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$connectedTable = $this->option('connected-table');
if ($artworkId !== null && $artworkId <= 0) {
$this->error('The --artwork-id option must be a positive integer.');
return self::FAILURE;
}
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
if ($artworkId !== null) {
$this->info("Scoping import to legacy artwork id={$artworkId}");
$query->where('id', $artworkId);
$limit = 1;
}
$processed = 0;
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
@@ -277,8 +292,14 @@ class ImportLegacyArtworks extends Command
return null;
}, 'id');
if ($artworkId !== null && $processed === 0) {
$this->warn("Legacy artwork id={$artworkId} was not found in {$legacyConn}.{$legacyTable}.");
return self::FAILURE;
}
$this->info('Import complete. Processed: ' . $processed);
return 0;
return self::SUCCESS;
}
}

View File

@@ -12,21 +12,22 @@ use Illuminate\Support\Facades\Log;
* Runs every 1015 minutes via scheduler.
*
* Formula:
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
* + comments_delta*8 + shares_delta*12
* raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6
* + comments_delta*8 + shares_delta*12) / window_hours)
*
* age_factor = 1 / (1 + hours_since_upload / 24)
*
* heat_score = raw_heat * age_factor
*
* Usage: php artisan nova:recalculate-heat
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --lookback-hours=24 --dry-run
*/
class RecalculateHeatCommand extends Command
{
protected $signature = 'nova:recalculate-heat
{--days=60 : Only process artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--lookback-hours=24 : Smooth heat deltas over this many trailing hours}
{--dry-run : Compute scores without writing to DB}';
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
@@ -44,31 +45,34 @@ class RecalculateHeatCommand extends Command
{
$days = (int) $this->option('days');
$chunk = (int) $this->option('chunk');
$lookbackHours = max(1, (int) $this->option('lookback-hours'));
$dryRun = (bool) $this->option('dry-run');
$now = now();
$currentHour = $now->copy()->startOfHour();
$prevHour = $currentHour->copy()->subHour();
$lookbackStart = $currentHour->copy()->subHours($lookbackHours);
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
$updatedCount = 0;
$skippedCount = 0;
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
// Process in chunks using artwork IDs that have at least one snapshot in the smoothing window
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
->whereIn('bucket_hour', [$currentHour, $prevHour])
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->distinct()
->pluck('artwork_id');
if ($artworkIds->isEmpty()) {
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
$this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.');
return self::SUCCESS;
}
// Load all snapshots for the two hours in bulk
// Load all snapshots for the lookback window in bulk
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->whereIn('bucket_hour', [$currentHour, $prevHour])
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->whereIn('artwork_id', $artworkIds)
->orderBy('bucket_hour')
->get()
->groupBy('artwork_id');
@@ -101,27 +105,57 @@ class RecalculateHeatCommand extends Command
}
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
if (! $currentSnapshot) {
$currentSnapshot = $artworkSnapshots->last();
}
// If we only have one snapshot, use it as current with zero deltas
if (!$currentSnapshot && !$prevSnapshot) {
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
$baselineSnapshot = $artworkSnapshots
->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? ''))
->first();
if (! $currentSnapshot) {
$skippedCount++;
continue;
}
// Calculate deltas
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
// One-hour counters remain explicit fields for dashboards and debugging.
$viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
$downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
$favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
$commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
$sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
// Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising.
// A single snapshot without an earlier baseline should not count as new momentum.
if ($baselineSnapshot) {
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0));
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0));
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0));
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0));
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0));
$windowHours = max(
1.0,
abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour)))
);
} else {
$viewsDelta = 0;
$downloadsDelta = 0;
$favouritesDelta = 0;
$commentsDelta = 0;
$sharesDelta = 0;
$windowHours = 1.0;
}
// Raw heat
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
+ ($downloadsDelta * self::WEIGHTS['downloads'])
+ ($favouritesDelta * self::WEIGHTS['favourites'])
+ ($commentsDelta * self::WEIGHTS['comments'])
+ ($sharesDelta * self::WEIGHTS['shares']);
$rawHeat = (
($viewsDelta * self::WEIGHTS['views'])
+ ($downloadsDelta * self::WEIGHTS['downloads'])
+ ($favouritesDelta * self::WEIGHTS['favourites'])
+ ($commentsDelta * self::WEIGHTS['comments'])
+ ($sharesDelta * self::WEIGHTS['shares'])
) / $windowHours;
// Age factor: favors newer works
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
@@ -134,11 +168,11 @@ class RecalculateHeatCommand extends Command
'artwork_id' => $artworkId,
'heat_score' => round($heatScore, 4),
'heat_score_updated_at' => $now,
'views_1h' => $viewsDelta,
'downloads_1h' => $downloadsDelta,
'favourites_1h' => $favouritesDelta,
'comments_1h' => $commentsDelta,
'shares_1h' => $sharesDelta,
'views_1h' => $viewsDelta1h,
'downloads_1h' => $downloadsDelta1h,
'favourites_1h' => $favouritesDelta1h,
'comments_1h' => $commentsDelta1h,
'shares_1h' => $sharesDelta1h,
];
$updatedCount++;

View File

@@ -13,7 +13,7 @@ use Carbon\Carbon;
class RepairLegacyWallzUsersCommand extends Command
{
protected $signature = 'skinbase:repair-legacy-wallz-users
protected $signature = 'legacySB:repair-legacy-wallz-users
{--chunk=500 : Number of legacy wallz rows to scan per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=wallz : Legacy table to update}

View File

@@ -64,6 +64,18 @@ class DashboardGalleryController extends Controller
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -74,13 +86,18 @@ class DashboardGalleryController extends Controller
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
),
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,

View File

@@ -6,9 +6,12 @@ namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* DiscoverFeedController
@@ -22,7 +25,10 @@ use Illuminate\Support\Facades\Cache;
*/
final class DiscoverFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __construct(
private readonly RSSFeedBuilder $builder,
private readonly AdaptiveTimeWindow $timeWindow,
) {}
/** /rss/discover → redirect to fresh */
public function index(): Response
@@ -77,15 +83,19 @@ final class DiscoverFeedController extends Controller
public function rising(): Response
{
$feedUrl = url('/rss/discover/rising');
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$artworks = Cache::remember(
"rss:discover:rising.{$windowDays}d",
600,
function () use ($windowDays) {
$artworks = $this->risingArtworks($windowDays);
if ($this->collectionHasNoRisingMomentum($artworks)) {
return $this->risingLowSignalArtworks($windowDays);
}
return $artworks;
}
);
return $this->builder->buildFromArtworks(
@@ -95,4 +105,76 @@ final class DiscoverFeedController extends Controller
$artworks,
);
}
private function risingArtworks(int $windowDays): Collection
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::public()
->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get();
}
private function risingLowSignalArtworks(int $windowDays): Collection
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::public()
->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
})
->select('artworks.*')
->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity')
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('recent_signal_24h')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get();
}
private function collectionHasNoRisingMomentum(Collection $artworks): bool
{
if ($artworks->isEmpty()) {
return false;
}
return $artworks->every(function (Artwork $artwork): bool {
return (float) ($artwork->heat_score ?? 0) <= 0
&& (float) ($artwork->engagement_velocity ?? 0) <= 0;
});
}
private function risingRecentActivitySubquery()
{
$since = now()->startOfHour()->subHours(24);
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
->selectRaw('rising_snapshots.artwork_id')
->selectRaw('(
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
) as recent_signal_24h')
->where('rising_snapshots.bucket_hour', '>=', $since)
->groupBy('rising_snapshots.artwork_id');
}
}

View File

@@ -280,11 +280,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
{
$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
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -295,9 +302,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'category_slug' => $primaryCategory->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,

View File

@@ -7,7 +7,7 @@ use App\Models\Artwork;
use App\Services\CommunityActivityService;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\EarlyGrowth\FeedBlender;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserSuggestionService;
@@ -33,8 +33,8 @@ final class DiscoverController extends Controller
public function __construct(
private readonly ArtworkService $artworkService,
private readonly ArtworkSearchService $searchService,
private readonly AdaptiveTimeWindow $timeWindow,
private readonly RecommendationFeedResolver $feedResolver,
private readonly FeedBlender $feedBlender,
private readonly GridFiller $gridFiller,
private readonly CommunityActivityService $communityActivity,
private readonly UserSuggestionService $userSuggestions,
@@ -45,9 +45,18 @@ final class DiscoverController extends Controller
public function trending(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverTrending($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
try {
$results = $this->searchService->discoverTrending($perPage);
} catch (\Throwable) {
$results = $this->fallbackTrendingFromDatabase($perPage, $windowDays);
}
if ($this->paginatorIsEmpty($results)) {
$results = $this->fallbackTrendingFromDatabase($perPage, $windowDays);
}
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -64,9 +73,22 @@ final class DiscoverController extends Controller
public function rising(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverRising($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
try {
$results = $this->searchService->discoverRising($perPage);
} catch (\Throwable) {
$results = $this->fallbackRisingFromDatabase($perPage, $windowDays);
}
if ($this->paginatorIsEmpty($results)) {
$results = $this->fallbackRisingFromDatabase($perPage, $windowDays);
}
if ($this->paginatorHasNoRisingMomentum($results)) {
$results = $this->fallbackRisingLowSignalFromDatabase($perPage, $windowDays);
}
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -83,11 +105,12 @@ final class DiscoverController extends Controller
public function fresh(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverFresh($perPage);
// EGS: blend fresh feed with curated + spotlight on page 1
$results = $this->feedBlender->blend($results, $perPage, $page);
$results = $this->gridFiller->fill($results, 0, $page);
if ($this->paginatorIsEmpty($results)) {
$results = $this->fallbackFreshFromDatabase($perPage);
}
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -351,6 +374,152 @@ final class DiscoverController extends Controller
// ─── Helpers ─────────────────────────────────────────────────────────────
private function paginatorIsEmpty($paginator): bool
{
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
return true;
}
$items = $paginator->getCollection();
return ! $items || $items->isEmpty();
}
private function paginatorHasNoRisingMomentum($paginator): bool
{
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
return true;
}
$items = $paginator->getCollection();
if (! $items || $items->isEmpty()) {
return true;
}
return $items->every(function ($item): bool {
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
return $heat <= 0.0 && $velocity <= 0.0;
});
}
private function fallbackFreshFromDatabase(int $perPage)
{
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->orderByDesc('published_at')
->orderByDesc('id')
->paginate($perPage)
->withQueryString();
}
private function fallbackTrendingFromDatabase(int $perPage, int $windowDays)
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('discover_stats.ranking_score')
->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('discover_stats.views')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->paginate($perPage)
->withQueryString();
}
private function fallbackRisingFromDatabase(int $perPage, int $windowDays)
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('discover_stats.heat_score')
->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->paginate($perPage)
->withQueryString();
}
private function fallbackRisingLowSignalFromDatabase(int $perPage, int $windowDays)
{
$cutoff = now()->subDays($windowDays)->startOfDay();
$recentActivity = $this->risingRecentActivitySubquery();
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->leftJoinSub($recentActivity, 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
})
->select('artworks.*')
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('recent_signal_24h')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->paginate($perPage)
->withQueryString();
}
private function risingRecentActivitySubquery()
{
$since = now()->startOfHour()->subHours(24);
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
->selectRaw('rising_snapshots.artwork_id')
->selectRaw('(
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
) as recent_signal_24h')
->where('rising_snapshots.bucket_hour', '>=', $since)
->groupBy('rising_snapshots.artwork_id');
}
private function hydrateDiscoverSearchResults($paginator): void
{
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
@@ -377,6 +546,7 @@ final class DiscoverController extends Controller
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->get()
@@ -398,9 +568,12 @@ final class DiscoverController extends Controller
'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 ?? '',
'uname' => $item->author_name ?? $item->author ?? $item->uname ?? 'Skinbase',
'username' => (($item->published_as_type ?? null) === 'group') ? '' : ($item->username ?? ''),
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64),
'profile_url' => $item->profile_url ?? null,
'published_as_type' => $item->published_as_type ?? null,
'publisher' => $item->publisher ?? null,
'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,
@@ -413,11 +586,18 @@ final class DiscoverController extends Controller
{
$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
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -429,8 +609,18 @@ final class DiscoverController extends Controller
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,

View File

@@ -276,11 +276,18 @@ final class ExploreController extends Controller
{
$primary = $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
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -291,9 +298,18 @@ final class ExploreController extends Controller
'category_slug' => $primary->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,

View File

@@ -290,11 +290,18 @@ final class SimilarArtworksPageController extends Controller
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -305,9 +312,18 @@ final class SimilarArtworksPageController extends Controller
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,

View File

@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Tags\TagDiscoveryService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
@@ -18,7 +17,6 @@ final class TagController extends Controller
{
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GridFiller $gridFiller,
private readonly TagDiscoveryService $tagDiscovery,
) {}
@@ -45,29 +43,10 @@ final class TagController extends Controller
public function show(Tag $tag, Request $request): View
{
$sort = $request->query('sort', 'popular'); // popular | latest | downloads
$sort = $request->query('sort', 'popular'); // popular | likes | latest | downloads
$perPage = min((int) $request->query('per_page', 24), 100);
// Convert sort param to Meili sort expression
$sortMap = [
'popular' => 'views:desc',
'likes' => 'likes:desc',
'latest' => 'created_at:desc',
'downloads' => 'downloads:desc',
];
$meiliSort = $sortMap[$sort] ?? 'views:desc';
$artworks = \App\Models\Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND tags = "' . addslashes($tag->slug) . '"',
'sort' => [$meiliSort],
])
->paginate($perPage)
->appends(['sort' => $sort]);
// EGS: ensure tag pages never show a half-empty grid on page 1
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks = $this->search->byTag($tag->slug, $perPage, $sort);
// Eager-load relations used by the gallery presenter and thumbnails.
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));

View File

@@ -7,10 +7,24 @@ namespace App\Http\Requests\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadChunkRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$uploadError = $this->detectChunkUploadError();
if ($uploadError !== null && $uploadError !== UPLOAD_ERR_OK) {
$this->logChunkUploadFailure($uploadError);
throw ValidationException::withMessages([
'chunk' => [$this->messageForUploadError($uploadError)],
]);
}
}
public function authorize(): bool
{
$user = $this->user();
@@ -79,6 +93,63 @@ final class UploadChunkRequest extends FormRequest
throw new NotFoundHttpException();
}
private function detectChunkUploadError(): ?int
{
$uploadedFile = $this->file('chunk');
if ($uploadedFile !== null) {
return (int) $uploadedFile->getError();
}
$rawError = data_get($_FILES, 'chunk.error');
if ($rawError === null || $rawError === '') {
return null;
}
return (int) $rawError;
}
private function messageForUploadError(int $error): string
{
return match ($error) {
UPLOAD_ERR_INI_SIZE => 'The upload chunk exceeded PHP upload_max_filesize. Lower UPLOAD_CHUNK_MAX_BYTES or raise upload_max_filesize/post_max_size.',
UPLOAD_ERR_FORM_SIZE => 'The upload chunk exceeded the allowed form upload size.',
UPLOAD_ERR_PARTIAL => 'The upload chunk was only partially received. Check Nginx/PHP-FPM request handling and network stability.',
UPLOAD_ERR_NO_FILE => 'No upload chunk file was received by PHP.',
UPLOAD_ERR_NO_TMP_DIR => 'PHP upload_tmp_dir is missing or unavailable. Check the configured temporary upload directory on the server.',
UPLOAD_ERR_CANT_WRITE => 'PHP could not write the upload chunk to the temporary directory. Check upload_tmp_dir permissions and free disk space.',
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload chunk before Laravel could process it.',
default => 'The upload chunk failed before Laravel could read it. Check PHP temporary upload storage and request size limits.',
};
}
private function logChunkUploadFailure(int $error): void
{
$uploadTmpDir = (string) (ini_get('upload_tmp_dir') ?: sys_get_temp_dir() ?: '');
$tmpExists = $uploadTmpDir !== '' ? is_dir($uploadTmpDir) : false;
$tmpWritable = $tmpExists ? is_writable($uploadTmpDir) : false;
logger()->warning('Upload chunk failed before validation completed', [
'session_id' => (string) $this->input('session_id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
'upload_error' => $error,
'upload_error_message' => $this->messageForUploadError($error),
'content_length' => $this->server('CONTENT_LENGTH'),
'post_max_size' => ini_get('post_max_size'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'upload_tmp_dir' => $uploadTmpDir,
'tmp_exists' => $tmpExists,
'tmp_writable' => $tmpWritable,
'raw_files' => isset($_FILES['chunk']) ? [
'name' => $_FILES['chunk']['name'] ?? null,
'type' => $_FILES['chunk']['type'] ?? null,
'size' => $_FILES['chunk']['size'] ?? null,
'tmp_name' => $_FILES['chunk']['tmp_name'] ?? null,
'error' => $_FILES['chunk']['error'] ?? null,
] : null,
]);
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Upload chunk unauthorized access', [

View File

@@ -61,6 +61,22 @@ class ArtworkListResource extends JsonResource
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$group = $this->relationLoaded('group') ? $this->group : null;
$user = $this->relationLoaded('user') ? $this->user : null;
$isGroupPublisher = $group !== null;
$publisher = ($group || $user)
? [
'type' => $isGroupPublisher ? 'group' : 'user',
'id' => (int) ($isGroupPublisher ? $group?->id : $user?->id),
'name' => $decode($isGroupPublisher ? $group?->name : $user?->name),
'username' => $isGroupPublisher ? '' : (string) ($user?->username ?? ''),
'avatar_url' => $isGroupPublisher ? $group?->avatarUrl() : $user?->profile?->avatar_url,
'profile_url' => $isGroupPublisher
? $group?->publicUrl()
: (! empty($user?->username) ? '/@' . $user->username : null),
]
: null;
return [
'id' => $artId,
'slug' => $slugVal,
@@ -71,12 +87,12 @@ class ArtworkListResource extends JsonResource
'height' => $get('height'),
],
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
'author' => $this->whenLoaded('user', function () use ($decode) {
return [
'name' => $decode($this->user->name ?? null),
'avatar_url' => $this->user?->profile?->avatar_url,
];
}),
'author' => $publisher,
'publisher' => $publisher,
'author_name' => $publisher['name'] ?? '',
'avatar_url' => $publisher['avatar_url'] ?? null,
'profile_url' => $publisher['profile_url'] ?? null,
'published_as_type' => $publisher['type'] ?? null,
'category' => $primaryCategory ? [
'slug' => $primaryCategory->slug ?? null,
'name' => $decode($primaryCategory->name ?? null),

View File

@@ -329,6 +329,7 @@ class Artwork extends Model
$stat = $this->stats;
$awardStat = $this->awardStat;
$publishedSortAt = $this->published_at ?? $this->created_at;
// Orientation derived from pixel dimensions
$orientation = 'square';
@@ -380,7 +381,8 @@ class Artwork extends Model
'downloads' => (int) ($stat?->downloads ?? 0),
'likes' => (int) ($stat?->favorites ?? 0),
'views' => (int) ($stat?->views ?? 0),
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
'created_at' => $publishedSortAt?->toDateString() ?? '',
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved,
// ── Trending / discovery fields ────────────────────────────────────

View File

@@ -255,6 +255,10 @@ class AppServiceProvider extends ServiceProvider
return $this->buildUploadLimits($request, 'init');
});
RateLimiter::for('uploads-chunk', function (Request $request): array {
return $this->buildUploadLimits($request, 'chunk');
});
RateLimiter::for('uploads-finish', function (Request $request): array {
return $this->buildUploadLimits($request, 'finish');
});

View File

@@ -9,6 +9,7 @@ use App\Models\Tag;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* High-level search API powered by Meilisearch via Laravel Scout.
@@ -19,6 +20,7 @@ final class ArtworkSearchService
{
private const BASE_FILTER = 'is_public = true AND is_approved = true';
private const CACHE_TTL = 300; // 5 minutes
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
@@ -82,22 +84,46 @@ final class ArtworkSearchService
/**
* Load artworks for a tag page, sorted by views + likes descending.
*/
public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator
public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator
{
$tag = Tag::where('slug', $slug)->first();
if (! $tag) {
return $this->emptyPaginator($perPage);
}
$cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1);
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
'sort' => ['views:desc', 'likes:desc'],
])
->paginate($perPage);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
$query = Artwork::query()
->public()
->published()
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->with(['user.profile', 'categories.contentType']);
match ($sort) {
'likes' => $query
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
->orderByDesc('artworks.published_at'),
'latest' => $query
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id'),
'downloads' => $query
->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC')
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
->orderByDesc('artworks.published_at'),
default => $query
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
->orderByDesc('artworks.published_at'),
};
return $query
->paginate($perPage)
->withQueryString();
});
}
@@ -125,12 +151,12 @@ final class ArtworkSearchService
* Used by categoryPageSort() and contentTypePageSort().
*/
private const CATEGORY_SORT_FIELDS = [
'trending' => ['trending_score_24h:desc', 'created_at:desc'],
'fresh' => ['created_at:desc'],
'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'],
'fresh' => ['published_at_ts: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'],
'oldest' => ['published_at_ts:asc'],
];
/** Cache TTL (seconds) per sort alias for category pages. */
@@ -237,7 +263,7 @@ final class ArtworkSearchService
}
/**
* Most recent artworks by created_at.
* Most recent artworks by publish timestamp.
*/
public function recent(int $perPage = 24): LengthAwarePaginator
{
@@ -245,7 +271,7 @@ final class ArtworkSearchService
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['created_at:desc'],
'sort' => ['published_at_ts:desc'],
])
->paginate($perPage);
});
@@ -294,7 +320,7 @@ final class ArtworkSearchService
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
])
->paginate($perPage);
});
@@ -310,7 +336,7 @@ final class ArtworkSearchService
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['created_at:desc'],
'sort' => ['published_at_ts:desc'],
])
->paginate($perPage);
});
@@ -378,7 +404,7 @@ final class ArtworkSearchService
}
/**
* Fresh artworks in given categories, sorted by created_at desc.
* Fresh artworks in given categories, sorted by publish timestamp desc.
* Used for personalized "Fresh in your favourite categories" section.
*
* @param string[] $categorySlugs
@@ -400,7 +426,7 @@ final class ArtworkSearchService
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['created_at:desc'],
'sort' => ['published_at_ts:desc'],
])
->paginate($limit);
});

View File

@@ -23,6 +23,24 @@ class ArtworkService
{
protected int $cacheTtl = 3600; // seconds
/**
* Lightweight relations needed to render browse/list cards.
*
* @return array<int|string, mixed>
*/
private function browseRelations(): array
{
return [
'user:id,name,username',
'user.profile:user_id,avatar_url',
'group:id,name,slug,avatar_path',
'categories' => function ($q) {
$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']);
},
];
}
/**
* Shared browse query used by /browse, content-type pages, and category pages.
*/
@@ -30,13 +48,7 @@ class ArtworkService
{
$query = Artwork::public()
->published()
->with([
'user:id,name',
'categories' => function ($q) {
$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']);
},
]);
->with($this->browseRelations());
$normalizedSort = strtolower(trim($sort));
if ($normalizedSort === 'oldest') {
@@ -110,6 +122,7 @@ class ArtworkService
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
{
$query = Artwork::public()->published()
->with($this->browseRelations())
->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
})

View File

@@ -63,7 +63,7 @@ final class AdaptiveTimeWindow
{
$ttl = (int) config('early_growth.cache_ttl.time_window', 600);
return Cache::remember('egs.uploads_per_day', $ttl, function (): float {
return (float) Cache::remember('egs.uploads_per_day', $ttl, function (): float {
$count = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
@@ -72,7 +72,7 @@ final class AdaptiveTimeWindow
->where('published_at', '>=', now()->subDays(7))
->count();
return round($count / 7, 2);
return (float) round($count / 7, 2);
});
}
}

View File

@@ -244,6 +244,15 @@ final class HomepageService
}
public function getHomepageGroups(?\App\Models\User $viewer = null): array
{
if (! $viewer) {
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
}
return $this->buildHomepageGroups($viewer);
}
private function buildHomepageGroups(?\App\Models\User $viewer = null): array
{
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
$spotlight = $featured[0] ?? null;
@@ -314,6 +323,10 @@ final class HomepageService
return $this->getRisingFromDb($limit);
}
if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
return $this->getRisingLowSignalFromDb($limit);
}
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
@@ -348,6 +361,26 @@ final class HomepageService
->all();
}
private function getRisingLowSignalFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
})
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
*
@@ -466,26 +499,38 @@ final class HomepageService
try {
$since = now()->subWeek();
$rows = DB::table('artworks')
->join('users as u', 'u.id', '=', 'artworks.user_id')
$weeklyUploads = Artwork::query()
->selectRaw('user_id, COUNT(*) as weekly_uploads')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where('published_at', '>=', $since)
->groupBy('user_id');
$rows = DB::table('users as u')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
$join->on('weekly_uploads.user_id', '=', 'u.id');
})
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->whereNotNull('artworks.published_at')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
->whereNull('u.deleted_at')
->where('u.is_active', true)
->where(function ($query): void {
$query->where('us.uploads_count', '>', 0)
->orWhere('weekly_uploads.weekly_uploads', '>', 0);
})
->orderByDesc('weekly_uploads')
->orderByDesc('total_awards')
->orderByDesc('total_views')
@@ -494,18 +539,23 @@ final class HomepageService
$userIds = $rows->pluck('id')->all();
// Pick one random artwork thumbnail per creator for the card background.
$thumbsByUser = Artwork::public()
$latestArtworkIds = Artwork::public()
->published()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->inRandomOrder()
->selectRaw('MAX(id) as id')
->groupBy('user_id')
->pluck('id')
->all();
$thumbsByUser = Artwork::query()
->whereIn('id', $latestArtworkIds)
->get(['id', 'user_id', 'hash', 'thumb_ext'])
->groupBy('user_id');
->keyBy('user_id');
return $rows->map(function ($u) use ($thumbsByUser) {
$artworkForBg = $thumbsByUser->get($u->id)?->first();
$artworkForBg = $thumbsByUser->get($u->id);
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
return [
@@ -792,6 +842,37 @@ final class HomepageService
return $artworks;
}
private function collectionHasNoRisingMomentum(Collection $items): bool
{
if ($items->isEmpty()) {
return true;
}
return $items->every(function ($item): bool {
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
return $heat <= 0.0 && $velocity <= 0.0;
});
}
private function risingRecentActivitySubquery()
{
$since = now()->startOfHour()->subHours(24);
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
->selectRaw('rising_snapshots.artwork_id')
->selectRaw('(
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
) as recent_signal_24h')
->where('rising_snapshots.bucket_hour', '>=', $since)
->groupBy('rising_snapshots.artwork_id');
}
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$thumbMd = $artwork->thumbUrl('md');

View File

@@ -407,6 +407,7 @@ final class CreatorStudioContentService
{
$now = Carbon::now();
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
$status = (string) ($item['status'] ?? '');
$isDraft = ($item['status'] ?? null) === 'draft';
$missing = [];
$score = 0;
@@ -441,6 +442,16 @@ final class CreatorStudioContentService
default => 'Needs more work',
};
$readiness = $status === 'published'
? null
: [
'score' => $score,
'max' => 4,
'label' => $label,
'can_publish' => $score >= 3,
'missing' => $missing,
];
$workflowActions = match ((string) ($item['module'] ?? '')) {
'artworks' => [
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
@@ -466,13 +477,7 @@ final class CreatorStudioContentService
'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)),
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
'readiness' => [
'score' => $score,
'max' => 4,
'label' => $label,
'can_publish' => $score >= 3,
'missing' => $missing,
],
'readiness' => $readiness,
'cross_module_actions' => $workflowActions,
];

View File

@@ -50,9 +50,10 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
$draftCount = (clone $baseQuery)
->whereNull('deleted_at')
->where(function (Builder $query): void {
$query->where('is_public', false)
->orWhere('artwork_status', 'draft');
$query->whereNull('artwork_status')
->orWhere('artwork_status', '!=', 'scheduled');
})
->where('is_public', false)
->count();
$publishedCount = (clone $baseQuery)
@@ -92,16 +93,29 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
$query = Artwork::query()
->withTrashed()
->where('user_id', $user->id)
->with(['stats', 'categories', 'tags'])
->with([
'stats',
'categories',
'tags',
'features' => function ($query): void {
$query->where('is_active', true)
->whereNull('deleted_at')
->where(function (Builder $builder): void {
$builder->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
},
])
->orderByDesc('updated_at')
->limit($limit);
if ($bucket === 'drafts') {
$query->whereNull('deleted_at')
->where(function (Builder $builder): void {
$builder->where('is_public', false)
->orWhere('artwork_status', 'draft');
});
$builder->whereNull('artwork_status')
->orWhere('artwork_status', '!=', 'scheduled');
})
->where('is_public', false);
} elseif ($bucket === 'scheduled') {
$query->whereNull('deleted_at')
->where('artwork_status', 'scheduled');
@@ -199,7 +213,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
'published_at' => $artwork->published_at?->toIso8601String(),
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
'schedule_timezone' => $artwork->artwork_timezone,
'featured' => false,
'featured' => $artwork->features->isNotEmpty(),
'metrics' => [
'views' => (int) ($stats?->views ?? 0),
'appreciation' => (int) ($stats?->favorites ?? 0),

View File

@@ -31,6 +31,8 @@ final class StudioBulkActionService
$query = Artwork::where('user_id', $userId);
if ($action === 'unarchive') {
$query->onlyTrashed();
} elseif ($action === 'delete') {
$query->withTrashed();
}
$artworks = $query->whereIn('id', $artworkIds)->get();

View File

@@ -105,6 +105,7 @@ return [
],
'sortableAttributes' => [
'created_at',
'published_at_ts',
'downloads',
'likes',
'views',

View File

@@ -97,6 +97,10 @@ return [
'per_user' => env('UPLOAD_RATE_INIT_USER', 10),
'per_ip' => env('UPLOAD_RATE_INIT_IP', 30),
],
'chunk' => [
'per_user' => env('UPLOAD_RATE_CHUNK_USER', 180),
'per_ip' => env('UPLOAD_RATE_CHUNK_IP', 360),
],
'finish' => [
'per_user' => env('UPLOAD_RATE_FINISH_USER', 6),
'per_ip' => env('UPLOAD_RATE_FINISH_IP', 12),
@@ -126,6 +130,7 @@ return [
'max_bytes' => env('UPLOAD_CHUNK_MAX_BYTES', 5242880),
'lock_seconds' => env('UPLOAD_CHUNK_LOCK_SECONDS', 10),
'lock_wait_seconds' => env('UPLOAD_CHUNK_LOCK_WAIT_SECONDS', 5),
'request_timeout_ms' => env('UPLOAD_CHUNK_REQUEST_TIMEOUT_MS', 45000),
],
'scan' => [

View File

@@ -1,5 +1,5 @@
[program:skinbase-queue]
command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,vision,recommendations,discovery,mail,default
command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
process_name=%(program_name)s_%(process_num)02d
numprocs=1
autostart=true

View File

@@ -8,7 +8,7 @@ Group=www-data
Restart=always
RestartSec=3
WorkingDirectory=/var/www/skinbase
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,vision,recommendations,discovery,mail,default
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=skinbase-queue

93
docs/Discover/README.md Normal file
View File

@@ -0,0 +1,93 @@
# Discover Pages
This folder documents how each public discovery surface is assembled today.
It is intentionally page-oriented rather than architecture-oriented.
For the broader discovery engine, signal collection, and personalization background, also see `docs/discovery-personalization-engine.md`.
## Pages Covered
- `Trending``GET /discover/trending`
- `Rising``GET /discover/rising`
- `Fresh``GET /discover/fresh`
- `Top Rated``GET /discover/top-rated`
- `Most Downloaded``GET /discover/most-downloaded`
- `Today Downloads``GET /downloads/today`
- `On This Day``GET /discover/on-this-day`
- `For You``GET /discover/for-you` (auth only)
## Shared Request Pipeline
Most Discover pages follow this pattern:
1. Route enters `App\Http\Controllers\Web\DiscoverController`.
2. The controller calls `App\Services\ArtworkSearchService`.
3. `ArtworkSearchService` queries the `artworks` Meilisearch index through Laravel Scout.
4. The search result usually contains only search/index fields.
5. `DiscoverController::hydrateDiscoverSearchResults()` then reloads full `Artwork` rows from MySQL with relations (`user`, `profile`, `categories`) and converts them into the view model used by Blade.
6. Some pages can still pass through additional presentation layers such as `GridFiller`, depending on the controller action.
## Shared Visibility Rules
All search-backed pages use the same base visibility filter in `ArtworkSearchService`:
```text
is_public = true AND is_approved = true
```
That means an artwork must be:
- public
- approved
- present in the Meilisearch index
If the database row is correct but search is stale, the page can still miss the artwork until indexing catches up.
## Shared Cache Behavior
`ArtworkSearchService` uses application cache in front of Meilisearch.
- Default TTL: 300 seconds
- `Rising`: 120 seconds
- Category/content-type sort pages use per-sort TTLs, but those are outside this folder's scope
The page can therefore lag behind a real publish or stat change even when the underlying data is already correct.
## Shared Supporting Jobs
These jobs are active in the current Laravel 11 runtime scheduler (`routes/console.php`):
- `skinbase:flush-redis-stats` every 5 minutes
- `skinbase:recalculate-trending --period=24h` every 30 minutes
- `skinbase:recalculate-trending --period=7d --skip-index` every 30 minutes
- `skinbase:reset-windowed-stats --period=24h` daily at 03:30
- `skinbase:reset-windowed-stats --period=7d` weekly (Monday) at 03:30
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes
- `artworks:publish-scheduled` every minute
- `analytics:aggregate-discovery-feedback` daily at 03:25
- `RecBuildItemPairsFromFavouritesJob` every 4 hours
- `RecComputeSimilarByTagsJob` daily at 02:00
- `RecComputeSimilarByBehaviorJob` daily at 02:15
- `RecComputeSimilarHybridJob` daily at 02:30
## Important Scheduler Caveat
The codebase still contains some discovery-related schedules inside `app/Console/Kernel.php`, but the active Laravel 11 runtime schedule comes from `routes/console.php`.
The Rising pipeline depends on these active runtime jobs:
- `nova:metrics-snapshot-hourly` hourly
- `nova:recalculate-heat` every 15 minutes
If Rising stops moving while Trending changes, check `php artisan schedule:list` first and confirm both jobs are still active.
## File Map
- `trending.md`
- `rising.md`
- `fresh.md`
- `top-rated.md`
- `most-downloaded.md`
- `today-downloads.md`
- `on-this-day.md`
- `for-you.md`

174
docs/Discover/for-you.md Normal file
View File

@@ -0,0 +1,174 @@
# For You
## Route
- URL: `GET /discover/for-you`
- Auth required: yes
- Controller: `App\Http\Controllers\Web\DiscoverController::forYou()`
- Entry service: `App\Services\Recommendations\RecommendationFeedResolver`
## High-level flow
`For You` is not a simple search sort.
It is a recommendation pipeline with engine selection, caching, layered candidate generation, reranking, and cursor pagination.
The controller:
1. reads `limit` and `cursor`
2. calls `RecommendationFeedResolver::getFeed()`
3. converts feed items into the artwork card view model
4. returns HTML or JSON depending on request type
## Engine selection
`RecommendationFeedResolver` chooses between two implementations:
- V2: `App\Services\Recommendations\RecommendationServiceV2`
- V1: `App\Services\Recommendations\PersonalizedFeedService`
Selection is based on:
- `config('discovery.v2.enabled')`
- rollout percentage bucket for the current user
- explicit `algo_version` override
## Cache model
Both engines use `user_recommendation_cache`.
At request time:
1. Load cache row for `(user_id, algo_version)`
2. Check cache version and `expires_at`
3. If missing or stale, dispatch `RegenerateUserRecommendationCacheJob`
4. If the row is empty, build fallback recommendations inline for the current request
This means the page is usually cache-backed, but it does not hard-fail if the cache is cold.
## V2 pipeline
V2 is the richer layered engine.
### Candidate layers
The candidate pool is blended from:
- personalized layer
- social layer
- trending layer
- exploration layer
- vector layer (only when V3/vector support is enabled and configured)
Default target ratios from `config/discovery.php`:
- personalized: 50%
- social: 20%
- trending: 20%
- exploration: 10%
### Main V2 score
For each candidate row:
```text
score
= (base_score * weight_base)
+ session_boost
+ social_boost
+ trending_boost
+ exploration_boost
+ creator_boost
+ vector_boost
- negative_penalty
- repetition_penalty
```
Where:
- `session_boost` comes from merged session/profile signals
- `social_boost` comes from followed creators and artworks liked by followed creators
- `trending_boost` is built from `trending_score_1h`, `trending_score_24h`, `trending_score_7d`
- `exploration_boost` rewards fresh uploads, new creators, and unseen tags
- `creator_boost` uses creator follower count plus artwork momentum metrics
- `vector_boost` comes from visual similarity when vector mode is enabled
- `negative_penalty` reflects hidden artworks and disliked tags
- `repetition_penalty` suppresses creator and tag repetition inside one result page
### Trending contribution inside V2
V2 combines the artwork's stored trending columns like this:
```text
trendingBoost
= (trending_score_1h * weight_1h)
+ (trending_score_24h * weight_24h)
+ (trending_score_7d * weight_7d)
```
Then divides by 100 before merging into the final score.
### Negative signals
V2 reads `user_negative_signals` and applies:
- hidden artwork exclusion
- disliked tag penalty
### Supporting data sources
V2 reads from:
- `user_recommendation_cache`
- session/profile signal builders
- `artworks`
- `artwork_stats`
- `artwork_similarities`
- `artwork_embeddings` and vector service, when enabled
- `user_followers`
- `artwork_favourites`
- `user_negative_signals`
## V1 pipeline
V1 is simpler and category-affinity driven.
It reads `user_interest_profiles`, then scores candidates with a weighted blend:
```text
score = (w1 * affinity)
+ (w2 * recency)
+ (w3 * popularity)
+ (w4 * novelty)
```
Cold start falls back to a blend of popular artworks and `artwork_similarities` seeds.
## Background jobs and schedules
### Directly relevant
- `RegenerateUserRecommendationCacheJob`
- dispatched on demand when cache is missing or stale
### Support jobs for candidate quality
- `RecBuildItemPairsFromFavouritesJob` every 4 hours
- `RecComputeSimilarByTagsJob` daily at 02:00
- `RecComputeSimilarByBehaviorJob` daily at 02:15
- `RecComputeSimilarHybridJob` daily at 02:30
- `analytics:aggregate-discovery-feedback` daily at 03:25
These jobs do not directly render the page, but they improve the offline inputs and behavioral data used by the recommender.
## Cache behavior
- V1 cache TTL default: 60 minutes
- V2 cache TTL default: 15 minutes
Cursor pagination is offset-based under the hood.
## Notes
- `For You` is the most configuration-sensitive page in this set.
- What a given user sees can differ by rollout bucket, `algo_version`, cache state, and whether V2/V3 features are enabled.
- If you are debugging a single user's page, inspect `RecommendationFeedResolver::inspectDecision()` first.

88
docs/Discover/fresh.md Normal file
View File

@@ -0,0 +1,88 @@
# Fresh
## Route
- URL: `GET /discover/fresh`
- Controller: `App\Http\Controllers\Web\DiscoverController::fresh()`
- Service: `App\Services\ArtworkSearchService::discoverFresh()`
## What the page reads
Fresh is Meilisearch-backed, then hydrated from MySQL.
The page does not directly query `artworks` for ranking order.
It relies on the search index being up to date.
If the search-backed result comes back empty, the controller falls back to a direct MySQL query ordered by `published_at DESC, id DESC` so the page does not render blank while search is catching up.
## Search query
`discoverFresh()` uses:
- filter: `is_public = true AND is_approved = true`
- sort:
- `published_at_ts:desc`
`published_at_ts` is a numeric timestamp field stored in the search document specifically so same-day uploads can be ordered correctly by hour and minute.
## Why the dedicated timestamp field exists
Historically, the index sorted by a date-only `created_at` string, which meant all uploads on the same calendar day could collapse to the same sort value.
The current implementation uses `published_at_ts` to preserve intra-day ordering and avoid newer uploads being buried behind older uploads from the same date.
## Page behavior
Fresh now uses the raw newest-first search result without any curated blending or grid filler injection.
That means:
- page 1 is not mixed with spotlight content
- page 1 is not padded with older trending artworks
- deeper pages follow the same ordering model as page 1
If older artworks appear near the top, the likely causes are stale search documents or stale cache, not intentional feed mixing.
If the page renders completely empty even though recent public artworks exist, the DB fallback should populate it. A blank page after that points to a real data visibility problem, not just search freshness.
## Data sources
Ranking eligibility depends on:
- `is_public`
- `is_approved`
- presence in Meilisearch
- `published_at_ts` in the indexed document
Hydration reads full rows from MySQL after the search query returns IDs.
When the fallback path is used, the page is served directly from MySQL and does not require Meilisearch for that request.
## Relevant jobs and schedules
Fresh does not have a dedicated score calculation job.
It depends on publication and indexing freshness.
Relevant active schedules:
- `artworks:publish-scheduled` every minute
- `skinbase:flush-redis-stats` every 5 minutes (not for ordering, but for displayed stats freshness)
Index freshness depends on:
- normal Scout indexing from artwork updates
- scheduled-publication indexing after an artwork transitions from scheduled to published
- manual/full imports after search-document schema changes
## Cache behavior
- Cache key: `discover.fresh.{page}`
- TTL: 300 seconds
## Notes
- Fresh is the page most sensitive to stale indexing because it is supposed to surface the latest publish action immediately.
- If an artwork is public in MySQL but absent from Fresh, the usual causes are:
- it has not been indexed yet
- app cache has not expired yet
- the artwork is not actually public and approved at the same time

View File

@@ -0,0 +1,59 @@
# Most Downloaded
## Route
- URL: `GET /discover/most-downloaded`
- Controller: `App\Http\Controllers\Web\DiscoverController::mostDownloaded()`
- Service: `App\Services\ArtworkSearchService::discoverMostDownloaded()`
## What the page reads
This page is Meilisearch-ranked and then hydrated from MySQL.
## Search query
`discoverMostDownloaded()` uses:
- filter: `is_public = true AND is_approved = true`
- sort:
- `downloads:desc`
- `views:desc`
In the indexed document:
- `downloads` is sourced from `artwork_stats.downloads`
- `views` is sourced from `artwork_stats.views`
This is therefore an all-time leaderboard, not a rolling-window leaderboard.
## Where the download counts come from
Downloads are recorded in two places:
1. `artwork_downloads`
- full event log of each tracked download
2. `artwork_stats.downloads`
- all-time aggregate counter used by this page
The page sorts on the aggregate counter, not by counting the event log live.
## Background jobs and schedules
No dedicated page-specific cron exists.
Relevant active maintenance:
- `skinbase:flush-redis-stats` every 5 minutes
- pushes deferred Redis counters into MySQL
Window reset commands exist too, but they maintain `downloads_24h` and `downloads_7d` rather than the all-time `downloads` column used by this page.
## Cache behavior
- Cache key: `discover.most-downloaded.{page}`
- TTL: 300 seconds
## Notes
- This page is separate from `Today Downloads`.
- If you need "what is hot today by download activity," use the dedicated `/downloads/today` page instead.

View File

@@ -0,0 +1,52 @@
# On This Day
## Route
- URL: `GET /discover/on-this-day`
- Controller: `App\Http\Controllers\Web\DiscoverController::onThisDay()`
## What the page reads
This page is a direct MySQL query over `artworks`.
It does not use Meilisearch.
## Query logic
The controller selects artworks that are:
- public
- published
- approved
- published on the same month/day as today
- from a previous year only
It then sorts them by `published_at DESC` and paginates 24 per page.
Equivalent logic:
```text
MONTH(published_at) = today.month
AND DAY(published_at) = today.day
AND YEAR(published_at) < today.year
```
## Data sources
- primary source: `artworks`
- supporting eager loads: `user`, `user.profile`, `categories`
## Cache behavior
- no explicit controller cache
## Background jobs and schedules
No dedicated cron drives this page.
It only depends on correct `published_at` values and the usual public/published scopes.
## Notes
- This is a calendar-history page, not a popularity or momentum page.
- The current implementation simply orders by newest qualifying `published_at`, not by views, downloads, or favourites.
- There are also legacy `TodayInHistoryController` variants elsewhere in the codebase, but `/discover/on-this-day` currently uses `DiscoverController::onThisDay()`.

115
docs/Discover/rising.md Normal file
View File

@@ -0,0 +1,115 @@
# Rising
## Route
- URL: `GET /discover/rising`
- Controller: `App\Http\Controllers\Web\DiscoverController::rising()`
- Service: `App\Services\ArtworkSearchService::discoverRising()`
- RSS feed: `GET /rss/discover/rising` via `App\Http\Controllers\RSS\DiscoverFeedController::rising()`
## What the page reads
Like most Discover surfaces, this page ranks via Meilisearch and then hydrates the result IDs from MySQL for presentation.
If the search-backed query throws or returns no items, the controller falls back to a direct MySQL query against `artworks` + `artwork_stats`.
If the page receives a non-empty result set but every item has zero `heat_score` and zero `engagement_velocity`, it switches to a low-signal fallback policy instead of pretending that the zero-heat order is meaningful.
The RSS Rising feed now follows the same low-signal policy and the same adaptive lookback window, so it does not drift to a stale zero-heat ordering when recent engagement is sparse.
Primary ranking fields:
- `heat_score`
- `engagement_velocity`
- `published_at_ts` as the final recency tie-breaker
## Search query
`discoverRising()` uses:
- filter: `is_public = true AND is_approved = true AND created_at >= cutoff`
- sort:
- `heat_score:desc`
- `engagement_velocity:desc`
- `published_at_ts:desc`
The cutoff comes from the same adaptive time-window service used by Trending.
## Rising formula
`heat_score` is produced by `App\Console\Commands\RecalculateHeatCommand`.
Current formula:
```text
raw_heat
= ((views_delta * 1)
+ (downloads_delta * 3)
+ (favourites_delta * 6)
+ (comments_delta * 8)
+ (shares_delta * 12)) / window_hours
age_factor
= 1 / (1 + hours_since_upload / 24)
heat_score
= raw_heat * age_factor
```
The heat command smooths deltas over a trailing lookback window, rather than relying only on the last single hour.
That matters on low-traffic periods, because a pure 1-hour delta often collapses to zero for almost every artwork.
An artwork still needs at least two snapshots inside that window for the smoothed heat delta to count. A single snapshot without an earlier baseline does not count as momentum.
The `views_1h`, `downloads_1h`, `favourites_1h`, `comments_1h`, and `shares_1h` columns are still stored from the previous-hour comparison for diagnostics and dashboards.
## Data sources
The page depends on:
- `artwork_metric_snapshots_hourly`
- `artwork_stats.heat_score`
- `artwork_stats.engagement_velocity`
- artwork publish timestamps
In zero-signal periods, the fallback policy also uses a 24-hour snapshot delta rollup from `artwork_metric_snapshots_hourly` and then falls back to `published_at DESC`.
`engagement_velocity` is not part of the heat command. It comes from the ranking engine and acts as a secondary momentum signal.
## Intended background jobs
The intended pipeline is:
1. `nova:metrics-snapshot-hourly`
- captures hourly totals into `artwork_metric_snapshots_hourly`
2. `nova:recalculate-heat`
- computes `heat_score` from snapshot deltas
3. Meilisearch picks up the updated score after indexing
## Runtime schedule
Rising depends on two active Laravel 11 runtime jobs in `routes/console.php`:
- `nova:metrics-snapshot-hourly`
- `nova:recalculate-heat`
If either one disappears from `php artisan schedule:list`, Rising will quickly drift toward stale or low-signal ordering.
## Active jobs that still affect Rising
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes updates `engagement_velocity`
- `skinbase:flush-redis-stats` every 5 minutes keeps all-time stats fresher
## Cache behavior
- Cache key: `discover.rising.{windowDays}d.{page}`
- TTL: 120 seconds
If Meilisearch sort settings are missing or the search result is empty, the controller falls back to the DB query instead of returning an empty page or a 500.
## Notes
- If Rising looks frozen while Trending moves, the first place to check is whether `nova:metrics-snapshot-hourly` and `nova:recalculate-heat` are actually being executed in production.
- The page no longer uses `GridFiller`, so it should not pull in unrelated older artworks when the real result set is thin.
- If all heat and velocity values are zero, Rising intentionally behaves like a low-signal discovery feed: recent activity in the last 24 hours first, then newest published artworks.

View File

@@ -0,0 +1,63 @@
# Today Downloads
## Route
- URL: `GET /downloads/today`
- Controller: `App\Http\Controllers\User\TodayDownloadsController::index()`
## Important scope note
This page is not inside `/discover/*`, but it is included here because it is discovery-adjacent and was requested together with the Discover surfaces.
## What the page reads
This page does not use Meilisearch.
It is a direct MySQL query over the download event log.
## Query logic
The controller:
1. Takes today's date
2. Reads `artwork_downloads`
3. Filters rows to `whereDate(created_at, today)`
4. Groups by `artwork_id`
5. Orders by `COUNT(*) DESC`
6. Eager-loads each artwork and related user/category data
Effectively:
```text
SELECT artwork_id, COUNT(*) AS num_downloads
FROM artwork_downloads
WHERE DATE(created_at) = today
GROUP BY artwork_id
ORDER BY num_downloads DESC
```
Only artworks that are currently public and published are allowed through `whereHas('artwork', ...)`.
## Data sources
- primary source: `artwork_downloads`
- supporting source: `artworks`, `users`, `user_profiles`, `categories`
Unlike `Most Downloaded`, this page does not trust the aggregate `artwork_stats.downloads` counter for ranking.
It re-counts today's actual events.
## Cache behavior
- no dedicated application cache in the controller
The page is as fresh as the underlying event log.
## Background jobs and schedules
No page-specific cron is needed because it reads the raw event log directly.
The only prerequisite is that downloads are being recorded correctly by the download endpoint.
## Notes
- This page is often the more operationally trustworthy answer to "what is being downloaded right now?"
- Because it counts raw rows, it is less sensitive to delayed aggregate-counter flushes than `Most Downloaded`.

View File

@@ -0,0 +1,58 @@
# Top Rated
## Route
- URL: `GET /discover/top-rated`
- Controller: `App\Http\Controllers\Web\DiscoverController::topRated()`
- Service: `App\Services\ArtworkSearchService::discoverTopRated()`
## What the page reads
This is a Meilisearch-ranked page with MySQL hydration after the fact.
## Search query
`discoverTopRated()` uses:
- filter: `is_public = true AND is_approved = true`
- sort:
- `likes:desc`
- `views:desc`
In the indexed document:
- `likes` is sourced from `artwork_stats.favorites`
- `views` is sourced from `artwork_stats.views`
So "Top Rated" really means highest favourite count, with views as a tie-breaker.
## Data sources
The page depends on:
- `artwork_stats.favorites`
- `artwork_stats.views`
- Scout/Meilisearch document freshness
It does not run a custom score formula beyond the sort order above.
## Background jobs and schedules
There is no dedicated top-rated cron.
The page depends on the freshness of the underlying stats.
Relevant active maintenance:
- `skinbase:flush-redis-stats` every 5 minutes for deferred stat deltas
Favorites themselves are typically updated in the normal request path rather than by a dedicated scheduled command.
## Cache behavior
- Cache key: `discover.top-rated.{page}`
- TTL: 300 seconds
## Notes
- Awards are not part of this page's ranking.
- If the business meaning should be "best overall" rather than "most favourited," this page would need a different sort field.

125
docs/Discover/trending.md Normal file
View File

@@ -0,0 +1,125 @@
# Trending
## Route
- URL: `GET /discover/trending`
- Controller: `App\Http\Controllers\Web\DiscoverController::trending()`
- Service: `App\Services\ArtworkSearchService::discoverTrending()`
## What the page actually reads
Primary ranking comes from Meilisearch, not directly from MySQL.
The controller flow is:
1. Query Meilisearch through `ArtworkSearchService`
2. If the search-backed query throws or returns no items, fall back to a direct MySQL query against `artworks` + `artwork_stats`
3. Hydrate the returned IDs back into full `Artwork` Eloquent models when needed
4. Render `resources/views/web/discover/index.blade.php`
So the page is effectively:
- ranking source: Meilisearch
- fallback ranking source: MySQL + `artwork_stats`
- display hydration source: MySQL
## Search query
`discoverTrending()` uses:
- filter: `is_public = true AND is_approved = true AND created_at >= cutoff`
- sort:
- `ranking_score:desc`
- `engagement_velocity:desc`
- `views:desc`
`created_at` here is the search index field, not necessarily the raw DB column semantics.
## Time window
The cutoff is not hardcoded to 30 days in all cases.
`App\Services\EarlyGrowth\AdaptiveTimeWindow` chooses the effective look-back window:
- 7 days when uploads/day is healthy
- 30 days in moderate activity
- 90 days in low activity
That widening only changes which artworks are eligible for the query.
It does not rewrite timestamps.
## Ranking formula
The page does not sort by `trending_score_7d` anymore.
It sorts by `ranking_score`, which is produced by `App\Services\Ranking\ArtworkRankingService`.
Current V2 ranking formula:
```text
base_score
= (views_all * 0.2)
+ (downloads_all * 1.5)
+ (favourites_all * 2.5)
+ (comments_count * 3.0)
+ (shares_count * 4.0)
authority_multiplier
= 1 + ((log10(1 + author_followers_count)
+ (author_favourites_received / 1000)) * 0.05)
decay_factor
= 1 / (1 + age_hours / 48)
velocity_boost
= ((views_24h * 1.0)
+ (favourites_24h * 3.0)
+ (comments_24h * 4.0)
+ (shares_24h * 5.0)) * 0.5
ranking_score
= (base_score * authority_multiplier * decay_factor) + velocity_boost
```
`engagement_velocity` is the raw `velocity_boost` term stored separately in `artwork_stats`.
## Where the numbers come from
The page depends mainly on:
- `artwork_stats.ranking_score`
- `artwork_stats.engagement_velocity`
- `artwork_stats.views`
- `artwork_stats.downloads`
- `artwork_stats.favorites`
- `artwork_stats.comments_count`
- `artwork_stats.shares_count`
- author-level follower and favourites-received signals
Those values are copied into the search document by `Artwork::toSearchableArray()`.
## Active jobs and schedules
Relevant active schedules:
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes
- `skinbase:flush-redis-stats` every 5 minutes
Indirectly relevant:
- `artworks:publish-scheduled` every minute
- Meilisearch indexing jobs dispatched after score recalculation
## Cache behavior
- Cache key: `discover.trending.{windowDays}d.{page}`
- TTL: 300 seconds
That means a ranking update can be correct in Meilisearch but still hidden by app cache for up to 5 minutes.
If Meilisearch sort settings are missing or the index returns no results, the controller falls back to a DB query instead of rendering a 500 or an empty page.
## Notes
- The view text still says "most-viewed artworks on Skinbase over the past 7 days," but the current implementation is really a `ranking_score` page with a dynamic eligibility window.
- The page is only as fresh as both the index and the app cache.
- The page no longer uses `GridFiller`, so it should not inject older out-of-window artworks just to pad page 1.

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -7,6 +7,7 @@ import Button from '../../components/ui/Button'
import Modal from '../../components/ui/Modal'
import FormField from '../../components/ui/FormField'
import Toggle from '../../components/ui/Toggle'
import NovaSelect from '../../components/ui/NovaSelect'
import TagPicker from '../../components/tags/TagPicker'
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
@@ -286,6 +287,29 @@ export default function StudioArtworkEdit() {
selectedRoot?.name || 'No root category',
subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null,
].filter(Boolean)
const publishingIdentityOptions = useMemo(() => {
const personalOption = {
value: '',
label: 'Personal profile',
icon: <i className="fa-solid fa-user text-[11px] text-sky-200" aria-hidden="true" />,
contextLabel: 'Publish under your own creator identity',
}
const groupItems = groupOptions.map((group) => ({
value: group.slug,
label: group.name,
icon: <i className="fa-solid fa-users text-[11px] text-violet-200" aria-hidden="true" />,
contextLabel: 'Publish under the shared group identity',
}))
return [personalOption, ...groupItems]
}, [groupOptions])
const primaryAuthorOptions = useMemo(() => currentContributorOptions.map((user) => ({
value: Number(user.id),
label: user.name || user.username,
username: user.username,
avatarUrl: user.avatar_url || null,
})), [currentContributorOptions])
// ── Handlers ───────────────────────────────────────────────────────────────
const handleContentTypeChange = (id) => {
@@ -1172,37 +1196,60 @@ export default function StudioArtworkEdit() {
</span>
</div>
<label className="block">
<span className="text-sm font-medium text-white/90">Publishing identity</span>
<select
value={groupSlug}
onChange={(event) => setGroupSlug(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
>
<option value="">Personal profile</option>
{groupOptions.map((group) => (
<option key={group.slug} value={group.slug}>{group.name}</option>
))}
</select>
{errors.group?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.group[0]}</p> : null}
</label>
<NovaSelect
label="Publishing identity"
value={groupSlug || ''}
onChange={(nextValue) => setGroupSlug(String(nextValue || ''))}
options={publishingIdentityOptions}
searchable={false}
placeholder="Choose publishing identity"
error={errors.group?.[0]}
hint={selectedGroupOption
? 'The artwork will be publicly published under the selected group while authorship stays editable below.'
: 'Personal publishing keeps the artwork under your own creator profile.'}
className="mt-2 bg-black/20"
renderOption={(option) => (
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04]">
{option.icon}
</span>
<span className="min-w-0">
<span className="block truncate font-medium text-white">{option.label}</span>
<span className="block truncate text-[11px] text-slate-500">{option.contextLabel}</span>
</span>
</span>
)}
/>
{groupSlug ? (
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div>
<label className="block">
<span className="text-sm font-medium text-white/90">Primary author</span>
<select
value={primaryAuthorUserId || ''}
onChange={(event) => setPrimaryAuthorUserId(event.target.value ? Number(event.target.value) : null)}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
>
{currentContributorOptions.map((user) => (
<option key={user.id} value={user.id}>{user.name || user.username}</option>
))}
</select>
</label>
{errors.primary_author_user_id?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.primary_author_user_id[0]}</p> : <p className="mt-2 text-xs text-slate-400">Primary author remains the lead creator shown on the public artwork page.</p>}
<NovaSelect
label="Primary author"
value={primaryAuthorUserId || null}
onChange={(nextValue) => setPrimaryAuthorUserId(nextValue ? Number(nextValue) : null)}
options={primaryAuthorOptions}
placeholder="Choose primary author"
searchable={primaryAuthorOptions.length > 6}
error={errors.primary_author_user_id?.[0]}
hint="Primary author remains the lead creator shown on the public artwork page."
className="mt-2 bg-black/20"
renderOption={(option) => (
<span className="flex min-w-0 items-center gap-3">
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="h-7 w-7 shrink-0 rounded-full object-cover" />
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-slate-400">
<i className="fa-solid fa-user text-[11px]" aria-hidden="true" />
</span>
)}
<span className="min-w-0">
<span className="block truncate font-medium text-white">{option.label}</span>
{option.username ? <span className="block truncate text-[11px] text-slate-500">@{option.username}</span> : null}
</span>
</span>
)}
/>
</div>
<div>

View File

@@ -1,12 +1,38 @@
import React, { useRef, useState } from 'react'
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
function slugifyGroupValue(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 90)
}
function resolveMediaPreviewUrl(path, filesCdnUrl) {
const trimmed = String(path || '').trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('blob:') || trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed
}
return `${String(filesCdnUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\/+/, '')}`
}
export default function StudioGroupCreate() {
const { props } = usePage()
const filesCdnUrl = props?.cdn?.files_url || ''
const avatarInputRef = useRef(null)
const bannerInputRef = useRef(null)
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
const [form, setForm] = useState({
name: '',
slug: '',
@@ -25,6 +51,8 @@ export default function StudioGroupCreate() {
})
const [avatarPreview, setAvatarPreview] = useState('')
const [bannerPreview, setBannerPreview] = useState('')
const resolvedAvatarPreview = useMemo(() => avatarPreview || resolveMediaPreviewUrl(form.avatar_path, filesCdnUrl), [avatarPreview, form.avatar_path, filesCdnUrl])
const resolvedBannerPreview = useMemo(() => bannerPreview || resolveMediaPreviewUrl(form.banner_path, filesCdnUrl), [bannerPreview, form.banner_path, filesCdnUrl])
const updateLink = (index, key, value) => {
setForm((current) => ({
@@ -72,6 +100,23 @@ export default function StudioGroupCreate() {
}
}
const handleNameChange = (event) => {
const nextName = event.target.value
setForm((current) => ({
...current,
name: nextName,
slug: slugManuallyEdited ? current.slug : slugifyGroupValue(nextName),
}))
}
const handleSlugChange = (event) => {
const nextSlug = slugifyGroupValue(event.target.value)
setSlugManuallyEdited(nextSlug !== '')
setForm((current) => ({ ...current, slug: nextSlug }))
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mx-auto mb-6 max-w-5xl">
@@ -94,11 +139,11 @@ export default function StudioGroupCreate() {
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-200">
<span>Name</span>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value, slug: current.slug || event.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={form.name} onChange={handleNameChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Slug</span>
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={form.slug} onChange={handleSlugChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Short description</span>
@@ -126,7 +171,7 @@ export default function StudioGroupCreate() {
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Avatar / logo</span>
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{avatarPreview || form.avatar_path ? <img src={avatarPreview || form.avatar_path} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
{resolvedAvatarPreview ? <img src={resolvedAvatarPreview} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
</div>
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
@@ -141,7 +186,7 @@ export default function StudioGroupCreate() {
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Cover image</span>
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{bannerPreview || form.banner_path ? <img src={bannerPreview || form.banner_path} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
{resolvedBannerPreview ? <img src={resolvedBannerPreview} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
</div>
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">

View File

@@ -2,9 +2,24 @@ import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function resolveMediaPreviewUrl(path, filesCdnUrl) {
const trimmed = String(path || '').trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('blob:') || trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed
}
return `${String(filesCdnUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\/+/, '')}`
}
export default function StudioGroupSettings() {
const { props } = usePage()
const group = props.studioGroup || {}
const filesCdnUrl = props?.cdn?.files_url || ''
const featuredArtworkOptions = Array.isArray(props.featuredArtworkOptions) ? props.featuredArtworkOptions : []
const avatarInputRef = useRef(null)
const bannerInputRef = useRef(null)
@@ -27,6 +42,8 @@ export default function StudioGroupSettings() {
})
const [avatarPreview, setAvatarPreview] = useState('')
const [bannerPreview, setBannerPreview] = useState('')
const resolvedAvatarPreview = useMemo(() => avatarPreview || resolveMediaPreviewUrl(form.avatar_path || group.avatar_url, filesCdnUrl), [avatarPreview, form.avatar_path, group.avatar_url, filesCdnUrl])
const resolvedBannerPreview = useMemo(() => bannerPreview || resolveMediaPreviewUrl(form.banner_path || group.banner_url, filesCdnUrl), [bannerPreview, form.banner_path, group.banner_url, filesCdnUrl])
const selectedFeaturedArtwork = useMemo(
() => featuredArtworkOptions.find((item) => Number(item.id) === Number(form.featured_artwork_id)) || null,
@@ -105,7 +122,7 @@ export default function StudioGroupSettings() {
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Avatar / logo</span>
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{avatarPreview || form.avatar_path || group.avatar_url ? <img src={avatarPreview || form.avatar_path || group.avatar_url} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
{resolvedAvatarPreview ? <img src={resolvedAvatarPreview} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
</div>
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
@@ -117,7 +134,7 @@ export default function StudioGroupSettings() {
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Cover image</span>
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{bannerPreview || form.banner_path || group.banner_url ? <img src={bannerPreview || form.banner_path || group.banner_url} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
{resolvedBannerPreview ? <img src={resolvedBannerPreview} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
</div>
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">

View File

@@ -17,6 +17,19 @@ const phases = {
error: 'error',
}
const DEFAULT_CHUNK_REQUEST_TIMEOUT_MS = 45000
const MIN_CHUNK_SIZE_BYTES = 256 * 1024
function formatChunkSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB'
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) === 0 ? 0 : 1)} MB`
return `${Math.max(1, Math.round(bytes / 1024))} KB`
}
function isRequestTooLarge(error) {
return Number(error?.response?.status || 0) === 413
}
const initialState = {
phase: phases.idle,
sessionId: null,
@@ -163,9 +176,14 @@ function getTypeKey(ct) {
return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
}
function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs, userId }) {
const [state, dispatch] = useReducer(reducer, { ...initialState, draftId })
const pollRef = useRef(null)
const adaptiveChunkSizeRef = useRef(Math.max(1, Number(chunkSize || 0)))
const effectiveChunkRequestTimeoutMs = (() => {
const parsed = Number(chunkRequestTimeoutMs)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_REQUEST_TIMEOUT_MS
})()
const extractErrorMessage = useCallback((error, fallback) => {
const message = error?.response?.data?.message
@@ -379,6 +397,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
try {
const res = await window.axios.post('/api/uploads/chunk', payload, {
timeout: effectiveChunkRequestTimeoutMs,
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
})
@@ -389,13 +408,16 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
return data
} catch (error) {
if (isRequestTooLarge(error)) {
throw error
}
if (attempt < MAX_CHUNK_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)))
return uploadChunk(sessionId, uploadToken, blob, offset, totalSize, attempt + 1)
}
throw error
}
}, [])
}, [effectiveChunkRequestTimeoutMs])
const uploadFile = useCallback(async (sessionId, uploadToken, file) => {
dispatch({ type: 'UPLOAD_START' })
@@ -415,7 +437,8 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
if (offset > totalSize) offset = 0
while (offset < totalSize) {
const nextOffset = Math.min(offset + chunkSize, totalSize)
const activeChunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(adaptiveChunkSizeRef.current || chunkSize || MIN_CHUNK_SIZE_BYTES))
const nextOffset = Math.min(offset + activeChunkSize, totalSize)
const chunk = file.slice(offset, nextOffset)
try {
@@ -425,6 +448,14 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
offset = nextOffset
}
} catch (error) {
if (isRequestTooLarge(error) && activeChunkSize > MIN_CHUNK_SIZE_BYTES) {
const nextChunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Math.floor(activeChunkSize / 2))
if (nextChunkSize < activeChunkSize) {
adaptiveChunkSizeRef.current = nextChunkSize
pushNotice('warning', `Server rejected ${formatChunkSize(activeChunkSize)} chunks. Retrying with ${formatChunkSize(nextChunkSize)} chunks.`)
continue
}
}
const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.')
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
pushMappedNotice(notice)
@@ -587,7 +618,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
}
}
export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) {
const { props } = usePage()
const windowFlags = window?.SKINBASE_FLAGS || {}
@@ -618,6 +649,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
<UploadWizard
initialDraftId={draftId ?? null}
chunkSize={chunkSize}
chunkRequestTimeoutMs={chunkRequestTimeoutMs}
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
groupOptions={Array.isArray(props?.group_options) ? props.group_options : []}
@@ -689,7 +721,13 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
const userId = props?.auth?.user?.id ?? null
const suggestedTags = Array.isArray(props?.suggested_tags) ? props.suggested_tags : []
const safeChunkSize = Math.max(1, Number(chunkSize || 0))
const { state, dispatch, previewUrl, startUpload, cancelUpload } = useUploadMachine({ draftId, filesCdnUrl, chunkSize: safeChunkSize, userId })
const { state, dispatch, previewUrl, startUpload, cancelUpload } = useUploadMachine({
draftId,
filesCdnUrl,
chunkSize: safeChunkSize,
chunkRequestTimeoutMs,
userId,
})
const fileInputRef = useRef(null)
const [confirmCancel, setConfirmCancel] = useState(false)
const [contentTypes, setContentTypes] = useState([])

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { router } from '@inertiajs/react'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import ConfirmDangerModal from './ConfirmDangerModal'
function formatDate(value) {
if (!value) return 'Unscheduled'
@@ -39,6 +40,23 @@ function statusClasses(status) {
}
}
function itemReadiness(item) {
if (item?.status === 'published') return null
return item?.workflow?.readiness ?? null
}
function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
return payload.errors[0]
}
return payload?.message
|| payload?.error
|| payload?.errors?.confirm?.[0]
|| payload?.errors?.action?.[0]
|| fallback
}
function ActionLink({ href, icon, label, onClick }) {
if (!href) return null
@@ -79,6 +97,8 @@ function PreviewLink({ item }) {
}
function GridCard({ item, onExecuteAction, busyKey }) {
const readiness = itemReadiness(item)
const handleEditClick = () => {
trackStudioEvent('studio_item_edited', {
surface: studioSurface(),
@@ -117,10 +137,10 @@ function GridCard({ item, onExecuteAction, busyKey }) {
</span>
</div>
{item.workflow?.readiness && (
{readiness && (
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
{item.workflow.readiness.label}
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
{readiness.label}
</span>
{item.workflow.is_stale_draft && (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-[11px] font-medium text-amber-100">
@@ -128,7 +148,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
</span>
)}
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">
{item.workflow.readiness.score}/{item.workflow.readiness.max} ready
{readiness.score}/{readiness.max} ready
</span>
</div>
)}
@@ -137,9 +157,9 @@ function GridCard({ item, onExecuteAction, busyKey }) {
{item.description || 'No description yet.'}
</p>
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
<div className="rounded-2xl border border-white/5 bg-slate-950/35 p-3 text-xs text-slate-400">
{item.workflow.readiness.missing.slice(0, 2).join(' • ')}
{readiness.missing.slice(0, 2).join(' • ')}
</div>
)}
@@ -186,6 +206,8 @@ function GridCard({ item, onExecuteAction, busyKey }) {
}
function ListRow({ item, onExecuteAction, busyKey }) {
const readiness = itemReadiness(item)
const handleEditClick = () => {
trackStudioEvent('studio_item_edited', {
surface: studioSurface(),
@@ -224,9 +246,9 @@ function ListRow({ item, onExecuteAction, busyKey }) {
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
<div className="mt-3 flex flex-wrap gap-2">
{item.workflow?.readiness && (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
{item.workflow.readiness.label}
{readiness && (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
{readiness.label}
</span>
)}
{item.workflow?.is_stale_draft && (
@@ -241,8 +263,8 @@ function ListRow({ item, onExecuteAction, busyKey }) {
<span>{metricValue(item, 'comments')} comments</span>
<span>Updated {formatDate(item.updated_at)}</span>
</div>
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
<div className="mt-3 text-xs text-slate-500">{item.workflow.readiness.missing.slice(0, 2).join(' • ')}</div>
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
<div className="mt-3 text-xs text-slate-500">{readiness.missing.slice(0, 2).join(' • ')}</div>
)}
</div>
@@ -262,13 +284,15 @@ function ListRow({ item, onExecuteAction, busyKey }) {
)
}
function AdvancedFilterControl({ filter, onChange }) {
function AdvancedFilterControl({ filter, onChange, value }) {
const controlValue = value ?? filter.value
if (filter.type === 'select') {
return (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<select
value={filter.value || 'all'}
value={controlValue || 'all'}
onChange={(event) => onChange(filter.key, event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
@@ -287,7 +311,7 @@ function AdvancedFilterControl({ filter, onChange }) {
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<input
type="search"
value={filter.value || ''}
value={controlValue || ''}
onChange={(event) => onChange(filter.key, event.target.value)}
placeholder={filter.placeholder || filter.label}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
@@ -306,23 +330,77 @@ export default function StudioContentBrowser({
}) {
const [viewMode, setViewMode] = useState('grid')
const [busyKey, setBusyKey] = useState(null)
const [selectedIds, setSelectedIds] = useState([])
const [bulkBusy, setBulkBusy] = useState(false)
const [optimisticRemovedIds, setOptimisticRemovedIds] = useState([])
const [pendingFilters, setPendingFilters] = useState({
q: '',
bucket: 'all',
sort: 'updated_desc',
category: 'all',
tag: '',
})
const [deleteDialog, setDeleteDialog] = useState({
open: false,
title: '',
message: '',
target: null,
})
const filters = listing?.filters || {}
const items = listing?.items || []
const meta = listing?.meta || {}
const advancedFilters = listing?.advanced_filters || []
const visibleItems = items.filter((item) => !optimisticRemovedIds.includes(Number(item.numeric_id)))
const currentModule = filters.module || listing?.module || items[0]?.module || null
const visibleQuickCreate = hideModuleFilter && currentModule && currentModule !== 'all'
? quickCreate.filter((action) => action.key === currentModule)
: quickCreate
const supportsArtworkBulk = currentModule === 'artworks' && items.every((item) => item.module === 'artworks')
const selectableIds = supportsArtworkBulk
? visibleItems.map((item) => Number(item.numeric_id)).filter((value) => Number.isInteger(value) && value > 0)
: []
const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
const selectedOnPage = selectedIds.filter((id) => selectableIds.includes(id))
const visibleTotal = Math.max(0, Number(meta.total || 0) - optimisticRemovedIds.length)
const filterControlCount = 1 + (hideModuleFilter ? 0 : 1) + (hideBucketFilter ? 0 : 1) + 1 + advancedFilters.length + 1
const filterGridClass = filterControlCount <= 4
? 'xl:grid-cols-4'
: filterControlCount === 5
? 'xl:grid-cols-5'
: filterControlCount === 6
? 'xl:grid-cols-6'
: 'xl:grid-cols-6 2xl:grid-cols-7'
useEffect(() => {
const stored = window.localStorage.getItem('studio-content-view')
if (stored === 'grid' || stored === 'list') {
if (stored === 'grid' || stored === 'list' || stored === 'table') {
setViewMode(stored)
return
}
if (listing?.default_view === 'grid' || listing?.default_view === 'list') {
if (listing?.default_view === 'grid' || listing?.default_view === 'list' || listing?.default_view === 'table') {
setViewMode(listing.default_view)
}
}, [listing?.default_view])
useEffect(() => {
setSelectedIds((current) => current.filter((id) => selectableIds.includes(id)))
}, [visibleItems, supportsArtworkBulk])
useEffect(() => {
setOptimisticRemovedIds([])
}, [items])
useEffect(() => {
setPendingFilters({
q: filters.q || '',
bucket: filters.bucket || 'all',
sort: filters.sort || 'updated_desc',
category: filters.category || 'all',
tag: filters.tag || '',
})
}, [filters.q, filters.bucket, filters.sort, filters.category, filters.tag])
const updateQuery = (patch) => {
const next = {
...filters,
@@ -360,11 +438,251 @@ export default function StudioContentBrowser({
})
}
const setPendingFilter = (key, value) => {
setPendingFilters((current) => ({
...current,
[key]: value,
}))
}
const submitSearch = () => {
updateQuery({
q: pendingFilters.q,
bucket: pendingFilters.bucket,
sort: pendingFilters.sort,
category: pendingFilters.category,
tag: pendingFilters.tag,
})
}
const addOptimisticallyRemovedIds = (ids) => {
setOptimisticRemovedIds((current) => Array.from(new Set([
...current,
...ids.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0),
])))
}
const toggleSelected = (numericId) => {
setSelectedIds((current) => current.includes(numericId)
? current.filter((id) => id !== numericId)
: [...current, numericId])
}
const toggleSelectAllVisible = () => {
if (!supportsArtworkBulk || selectableIds.length === 0) {
return
}
setSelectedIds((current) => {
if (allVisibleSelected) {
return current.filter((id) => !selectableIds.includes(id))
}
return Array.from(new Set([...current, ...selectableIds]))
})
}
const executeBulkAction = async (actionKey) => {
if (!supportsArtworkBulk || selectedIds.length === 0 || bulkBusy) {
return
}
const labels = {
publish: 'publish',
unpublish: 'move to draft',
archive: 'archive',
unarchive: 'restore',
delete: 'delete permanently',
}
if (actionKey === 'delete') {
setDeleteDialog({
open: true,
title: `Delete ${selectedIds.length} artwork${selectedIds.length === 1 ? '' : 's'}?`,
message: 'This permanently removes the selected artworks. This cannot be undone.',
target: {
kind: 'bulk',
actionKey,
ids: [...selectedIds],
},
})
return
}
if (!window.confirm(`Are you sure you want to ${labels[actionKey] || actionKey} ${selectedIds.length} artwork${selectedIds.length === 1 ? '' : 's'}?`)) {
return
}
setBulkBusy(true)
try {
const response = await fetch('/api/studio/artworks/bulk', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
action: actionKey,
artwork_ids: selectedIds,
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(bulkErrorMessage(payload))
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'artworks',
meta: {
bulk_action: actionKey,
count: selectedIds.length,
},
})
setSelectedIds([])
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Bulk action failed.')
} finally {
setBulkBusy(false)
}
}
const closeDeleteDialog = () => {
setDeleteDialog({
open: false,
title: '',
message: '',
target: null,
})
}
const confirmDeleteDialog = async () => {
const target = deleteDialog.target
if (!target) {
closeDeleteDialog()
return
}
if (target.kind === 'bulk') {
setBulkBusy(true)
try {
const response = await fetch('/api/studio/artworks/bulk', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
action: 'delete',
artwork_ids: target.ids,
confirm: 'DELETE',
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(bulkErrorMessage(payload))
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'artworks',
meta: {
bulk_action: 'delete',
count: target.ids.length,
},
})
addOptimisticallyRemovedIds(target.ids)
setSelectedIds([])
closeDeleteDialog()
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Bulk action failed.')
} finally {
setBulkBusy(false)
}
return
}
if (target.kind === 'single') {
const action = target.action
const requestKey = `${action.key}:${action.url}`
setBusyKey(requestKey)
try {
const response = await fetch(action.url, {
method: String(action.method || 'post').toUpperCase(),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: action.payload ? JSON.stringify(action.payload) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Request failed')
}
addOptimisticallyRemovedIds(action.payload?.artwork_ids || [])
closeDeleteDialog()
if (action.redirect_pattern && payload?.data?.id) {
window.location.assign(action.redirect_pattern.replace('__ID__', String(payload.data.id)))
return
}
if (payload?.redirect) {
window.location.assign(payload.redirect)
return
}
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Action failed.')
} finally {
setBusyKey(null)
}
}
}
const executeAction = async (action) => {
if (!action?.url || action.type !== 'request') {
return
}
if (action.key === 'delete') {
setDeleteDialog({
open: true,
title: 'Delete artwork permanently?',
message: action.confirm || 'This artwork will be permanently removed and cannot be restored.',
target: {
kind: 'single',
action,
},
})
return
}
if (action.confirm && !window.confirm(action.confirm)) {
return
}
@@ -439,13 +757,13 @@ export default function StudioContentBrowser({
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className={`grid gap-3 md:grid-cols-2 ${advancedFilters.length > 0 ? 'xl:grid-cols-5' : 'xl:grid-cols-4'}`}>
<div className={`grid gap-3 md:grid-cols-2 ${filterGridClass}`}>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<input
type="search"
value={filters.q || ''}
onChange={(event) => updateQuery({ q: event.target.value })}
value={pendingFilters.q}
onChange={(event) => setPendingFilter('q', event.target.value)}
placeholder="Title, description, module"
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
/>
@@ -472,8 +790,8 @@ export default function StudioContentBrowser({
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
<select
value={filters.bucket || 'all'}
onChange={(event) => updateQuery({ bucket: event.target.value })}
value={pendingFilters.bucket}
onChange={(event) => setPendingFilter('bucket', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.bucket_options || []).map((option) => (
@@ -488,8 +806,8 @@ export default function StudioContentBrowser({
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select
value={filters.sort || 'updated_desc'}
onChange={(event) => updateQuery({ sort: event.target.value })}
value={pendingFilters.sort}
onChange={(event) => setPendingFilter('sort', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.sort_options || []).map((option) => (
@@ -501,8 +819,31 @@ export default function StudioContentBrowser({
</label>
{advancedFilters.map((filter) => (
<AdvancedFilterControl key={filter.key} filter={filter} onChange={(key, value) => updateQuery({ [key]: value })} />
<AdvancedFilterControl
key={filter.key}
filter={filter}
value={filter.key === 'category' || filter.key === 'tag' ? pendingFilters[filter.key] : undefined}
onChange={(key, value) => {
if (key === 'category' || key === 'tag') {
setPendingFilter(key, value)
return
}
updateQuery({ [key]: value })
}}
/>
))}
<div className="flex items-end">
<button
type="button"
onClick={submitSearch}
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
>
<i className="fa-solid fa-magnifying-glass" />
<span>Search</span>
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
@@ -510,6 +851,7 @@ export default function StudioContentBrowser({
{[
{ value: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
{ value: 'table', icon: 'fa-solid fa-table-list', label: 'Table view' },
].map((option) => (
<button
key={option.value}
@@ -523,7 +865,7 @@ export default function StudioContentBrowser({
))}
</div>
{quickCreate.map((action) => (
{visibleQuickCreate.map((action) => (
<a
key={action.key}
href={action.url}
@@ -547,19 +889,179 @@ export default function StudioContentBrowser({
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
<p>
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> items
Showing <span className="font-semibold text-white">{visibleItems.length}</span> of <span className="font-semibold text-white">{visibleTotal.toLocaleString()}</span> items
</p>
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
</div>
{items.length > 0 ? (
{viewMode === 'table' && supportsArtworkBulk && (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
/>
<span>Select page</span>
</label>
<span className="text-slate-500">
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
</span>
</div>
<div className="flex flex-wrap gap-2">
{[
{ key: 'publish', label: 'Publish', icon: 'fa-solid fa-rocket' },
{ key: 'unpublish', label: 'Draft', icon: 'fa-solid fa-file-pen' },
{ key: 'archive', label: 'Archive', icon: 'fa-solid fa-box-archive' },
{ key: 'unarchive', label: 'Restore', icon: 'fa-solid fa-rotate-left' },
{ key: 'delete', label: 'Delete', icon: 'fa-solid fa-trash' },
].map((action) => (
<button
key={action.key}
type="button"
disabled={selectedIds.length === 0 || bulkBusy}
onClick={() => executeBulkAction(action.key)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs font-semibold text-slate-200 transition hover:border-white/20 hover:bg-black/30 disabled:cursor-not-allowed disabled:opacity-40"
>
<i className={action.icon} />
<span>{bulkBusy ? 'Working...' : action.label}</span>
</button>
))}
</div>
</section>
)}
{visibleItems.length > 0 ? (
viewMode === 'grid' ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{items.map((item) => <GridCard key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
{visibleItems.map((item) => <GridCard key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
</div>
) : viewMode === 'list' ? (
<div className="space-y-4">
{visibleItems.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
</div>
) : (
<div className="space-y-4">
{items.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
<thead className="bg-black/20 text-[11px] uppercase tracking-[0.18em] text-slate-500">
<tr>
{supportsArtworkBulk && (
<th scope="col" className="w-12 px-4 py-3">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label="Select all artworks on this page"
/>
</th>
)}
<th scope="col" className="px-4 py-3">Item</th>
<th scope="col" className="px-4 py-3">Status</th>
<th scope="col" className="px-4 py-3">Category</th>
<th scope="col" className="px-4 py-3">Updated</th>
<th scope="col" className="px-4 py-3">Stats</th>
<th scope="col" className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{visibleItems.map((item) => {
const isSelected = selectedOnPage.includes(Number(item.numeric_id))
return (
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
{supportsArtworkBulk && (
<td className="px-4 py-4">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelected(Number(item.numeric_id))}
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label={`Select ${item.title}`}
/>
</td>
)}
<td className="px-4 py-4">
<div className="flex min-w-[280px] items-start gap-3">
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-slate-950/60">
{item.image_url ? (
<img src={item.image_url} alt={item.title} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full items-center justify-center text-slate-500">
<i className={`${item.module_icon} text-lg`} />
</div>
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
<i className={`${item.module_icon} text-[10px]`} />
{item.module_label}
</span>
<span className="text-xs text-slate-500">#{item.numeric_id}</span>
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 text-xs text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</div>
{Array.isArray(item.taxonomies?.tags) && item.taxonomies.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{item.taxonomies.tags.slice(0, 3).map((tag) => (
<span key={`${item.id}-${tag.slug}`} className="rounded-full border border-white/10 px-2 py-1 text-[10px] text-slate-400">
{tag.name}
</span>
))}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-4">
<div className="space-y-2">
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
{String(item.status || 'unknown').replace('_', ' ')}
</span>
{itemReadiness(item) && (
<div>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(itemReadiness(item))}`}>
{itemReadiness(item).label}
</span>
</div>
)}
</div>
</td>
<td className="px-4 py-4 text-sm text-slate-400">
<div>{item.subtitle || item.taxonomies?.categories?.[0]?.name || 'Uncategorized'}</div>
<div className="mt-1 text-xs text-slate-500">{item.visibility || 'private'}</div>
</td>
<td className="px-4 py-4 text-sm text-slate-400">
<div>Updated {formatDate(item.updated_at)}</div>
<div className="mt-1 text-xs text-slate-500">Created {formatDate(item.created_at)}</div>
{item.published_at && <div className="mt-1 text-xs text-slate-500">Published {formatDate(item.published_at)}</div>}
</td>
<td className="px-4 py-4 text-sm text-slate-400">
<div>{metricValue(item, 'views')} views</div>
<div className="mt-1">{metricValue(item, 'appreciation')} reactions</div>
<div className="mt-1">{metricValue(item, 'comments')} comments</div>
</td>
<td className="px-4 py-4">
<div className="flex min-w-[220px] flex-wrap gap-2">
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" />
<PreviewLink item={item} />
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
{(item.actions || []).slice(0, 2).map((action) => (
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={executeAction} busyKey={busyKey} />
))}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
) : (
@@ -592,6 +1094,15 @@ export default function StudioContentBrowser({
<i className="fa-solid fa-arrow-right" />
</button>
</div>
<ConfirmDangerModal
open={deleteDialog.open}
onClose={closeDeleteDialog}
onConfirm={confirmDeleteDialog}
title={deleteDialog.title}
message={deleteDialog.message}
confirmText="DELETE"
/>
</div>
)
}

View File

@@ -119,6 +119,24 @@ function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function swapImageToFallbackOnce(event, fallbackSrc, { clearResponsive = false } = {}) {
const image = event.currentTarget
if (!image || image.dataset.fallbackApplied === '1') {
return
}
image.dataset.fallbackApplied = '1'
image.onerror = null
if (clearResponsive) {
image.removeAttribute('srcset')
image.removeAttribute('sizes')
}
image.src = fallbackSrc
}
function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return
@@ -437,18 +455,27 @@ export default function ArtworkCard({
const item = artwork || {}
const rawAuthor = item.author || item.creator
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null
const isGroupPublisher = (publisher?.type === 'group') || item.published_as_type === 'group'
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
const author = decodeHtml(
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
(isGroupPublisher ? publisher?.name : null)
|| (typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|| item.author_name
|| item.uname
|| 'Skinbase Artist'
)
const username = rawAuthor?.username || item.author_username || item.username || null
const authorLevel = Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
const authorRank = rawAuthor?.rank || item.author_rank || item.creator?.rank || ''
const username = isGroupPublisher ? null : (rawAuthor?.username || item.author_username || item.username || null)
const authorLevel = isGroupPublisher ? 0 : Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
const authorRank = isGroupPublisher ? '' : (rawAuthor?.rank || item.author_rank || item.creator?.rank || '')
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
const avatar = rawAuthor?.avatar_url || rawAuthor?.avatar || item.avatar || item.author_avatar || item.avatar_url || AVATAR_FALLBACK
const avatar = (isGroupPublisher ? publisher?.avatar_url : null)
|| rawAuthor?.avatar_url
|| rawAuthor?.avatar
|| item.avatar
|| item.author_avatar
|| item.avatar_url
|| AVATAR_FALLBACK
const likes = item.likes ?? item.favourites ?? 0
const views = item.views ?? item.views_count ?? item.view_count ?? 0
const downloads = item.downloads ?? item.downloads_count ?? item.download_count ?? 0
@@ -470,7 +497,7 @@ export default function ArtworkCard({
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
const authorHref = username ? `/@${username}` : null
const authorHref = publisher?.profile_url || rawAuthor?.profile_url || item.profile_url || item.author_url || (username ? `/@${username}` : null)
const resolvedMetricBadge = metricBadge || item.metric_badge || null
const relativePublishedAt = useMemo(
() => formatRelativeTime(item.published_at || item.publishedAt || null),
@@ -750,7 +777,7 @@ export default function ArtworkCard({
decoding={decoding}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(event) => {
event.currentTarget.src = IMAGE_FALLBACK
swapImageToFallbackOnce(event, IMAGE_FALLBACK)
}}
/>
</div>
@@ -761,7 +788,7 @@ export default function ArtworkCard({
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
{authorHref ? (
<span>
by {author} <span className="text-slate-500">@{username}</span>
by {author} {username ? <span className="text-slate-500">@{username}</span> : null}
</span>
) : (
<span>by {author}</span>
@@ -810,7 +837,7 @@ export default function ArtworkCard({
fetchPriority={fetchPriority || undefined}
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', imageClassName)}
onError={(event) => {
event.currentTarget.src = IMAGE_FALLBACK
swapImageToFallbackOnce(event, IMAGE_FALLBACK, { clearResponsive: true })
}}
/>
@@ -880,14 +907,14 @@ export default function ArtworkCard({
decoding="async"
className="h-9 w-9 shrink-0 rounded-full object-cover"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
swapImageToFallbackOnce(event, AVATAR_FALLBACK)
}}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
{author}
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
{username ? <span className="text-[11px] text-white/60"> @{username}</span> : null}
</span>
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
</span>

View File

@@ -6,6 +6,9 @@ const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, mediaWidth = null, mediaHeight = null, mediaKey = 'cover', onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
const [isLoaded, setIsLoaded] = useState(false)
const [mainImageMode, setMainImageMode] = useState('primary')
const [previewImageMode, setPreviewImageMode] = useState('primary')
const [showBackdrop, setShowBackdrop] = useState(true)
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.url || null
@@ -17,6 +20,19 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
const blurBackdropSrc = mdSource || lgSource || xlSource || null
const primaryMainSrc = lgSource || xlSource || mdSource || FALLBACK_LG
const primaryPreviewSrc = mdSource || lgSource || xlSource || FALLBACK_MD
const srcSet = [
mdSource ? `${mdSource} 640w` : null,
lgSource ? `${lgSource} 1280w` : null,
xlSource ? `${xlSource} 1920w` : null,
].filter(Boolean).join(', ')
const resolvedMainSrc = mainImageMode === 'fallback'
? FALLBACK_LG
: (mainImageMode === 'hidden' ? null : primaryMainSrc)
const resolvedPreviewSrc = previewImageMode === 'fallback'
? FALLBACK_MD
: (previewImageMode === 'hidden' ? null : primaryPreviewSrc)
const dbWidth = Number(mediaWidth ?? artwork?.width)
const dbHeight = Number(mediaHeight ?? artwork?.height)
@@ -30,6 +46,10 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
useEffect(() => {
setIsLoaded(false)
setMainImageMode('primary')
setPreviewImageMode('primary')
setShowBackdrop(true)
if (hasDbDims) {
setNaturalDims({ w: dbWidth, h: dbHeight })
return
@@ -47,16 +67,15 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
}
}
img.onerror = null
img.src = xlSource
}, [xlSource, naturalDims])
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
return (
<figure className="relative w-full overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-b from-nova-950 via-nova-900 to-nova-900 p-2 shadow-[0_35px_90px_-35px_rgba(15,23,36,0.9)] sm:p-4">
{blurBackdropSrc && (
{blurBackdropSrc && showBackdrop && (
<>
<img
src={blurBackdropSrc}
@@ -65,6 +84,10 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
loading="eager"
decoding="async"
onError={(event) => {
event.currentTarget.onerror = null
setShowBackdrop(false)
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-nova-950/55 via-nova-900/40 to-nova-950/70" />
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
@@ -102,29 +125,52 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
}
} : undefined}
>
<img
src={md}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain rounded-xl"
loading="eager"
decoding="async"
fetchPriority="high"
/>
{resolvedPreviewSrc ? (
<img
src={resolvedPreviewSrc}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain rounded-xl"
loading="eager"
decoding="async"
fetchPriority="high"
onError={(event) => {
event.currentTarget.onerror = null
<img
src={lg}
srcSet={srcSet}
sizes="(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw"
alt={artwork?.title ?? 'Artwork'}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="eager"
decoding="async"
fetchPriority="high"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.src = FALLBACK_LG
}}
/>
if (previewImageMode === 'primary') {
setPreviewImageMode('fallback')
return
}
setPreviewImageMode('hidden')
}}
/>
) : null}
{resolvedMainSrc ? (
<img
src={resolvedMainSrc}
srcSet={mainImageMode === 'primary' && srcSet !== '' ? srcSet : undefined}
sizes={mainImageMode === 'primary' && srcSet !== '' ? '(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw' : undefined}
alt={artwork?.title ?? 'Artwork'}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="eager"
decoding="async"
fetchPriority="high"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.onerror = null
if (mainImageMode === 'primary') {
setMainImageMode('fallback')
setIsLoaded(false)
return
}
setMainImageMode('hidden')
setIsLoaded(true)
}}
/>
) : null}
{onOpenViewer && (
<button

View File

@@ -150,14 +150,18 @@ function mapRankApiArtwork(item) {
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : 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 ?? '',
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
avatar_url: item.author?.avatar_url ?? null,
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
published_as_type: publisher?.type ?? null,
publisher: publisher,
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
category_name: item.category?.name ?? '',

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const DEFAULT_MAX_TAGS = 15
const DEFAULT_MIN_LENGTH = 2
@@ -27,6 +28,49 @@ function parseTagList(input) {
.filter(Boolean)
}
function analyzePastedTags(rawText, selectedTags, minLength, maxLength, maxTags) {
const parts = parseTagList(rawText)
const tagsToAdd = []
const skippedDuplicates = []
const skippedInvalid = []
const skippedOverflow = []
for (const part of parts) {
const normalized = normalizeTag(part)
if (!normalized) {
skippedInvalid.push(String(part ?? '').trim())
continue
}
if (selectedTags.includes(normalized) || tagsToAdd.includes(normalized)) {
skippedDuplicates.push(normalized)
continue
}
if (normalized.length < minLength || normalized.length > maxLength || !/^[a-z0-9_-]+$/.test(normalized)) {
skippedInvalid.push(String(part ?? '').trim())
continue
}
if (selectedTags.length + tagsToAdd.length >= maxTags) {
skippedOverflow.push(normalized)
continue
}
tagsToAdd.push(normalized)
}
return {
parsedCount: parts.length,
tagsToAdd,
nextTags: [...selectedTags, ...tagsToAdd],
skippedDuplicates,
skippedInvalid,
skippedOverflow,
}
}
function validateTag(tag, selectedTags, minLength, maxLength, maxTags) {
if (selectedTags.length >= maxTags) return 'Max tags reached'
if (tag.length < minLength) return 'Tag too short'
@@ -240,13 +284,124 @@ function StatusHints({ error, count, maxTags }) {
return (
<div className="flex items-center justify-between gap-3 text-xs">
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
{error || 'Type and press Enter, comma, or Tab to add'}
{error || 'Type and press Enter, comma, or Tab to add. Paste a comma-separated list to review multiple tags.'}
</span>
<span className="text-white/50">{count}/{maxTags}</span>
</div>
)
}
function PastedTagsDialog({ open, preview, maxTags, onClose, onConfirm }) {
const backdropRef = useRef(null)
const cancelButtonRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const timeoutId = window.setTimeout(() => cancelButtonRef.current?.focus(), 60)
return () => window.clearTimeout(timeoutId)
}, [open])
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || !preview) return null
const duplicateCount = preview.skippedDuplicates.length
const invalidCount = preview.skippedInvalid.length
const overflowCount = preview.skippedOverflow.length
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="tag-input-paste-title"
className="w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Tag Import</p>
<h3 id="tag-input-paste-title" className="mt-2 text-lg font-semibold text-white">
Add {preview.tagsToAdd.length} pasted tag{preview.tagsToAdd.length === 1 ? '' : 's'}?
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before adding them to the artwork.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{overflowCount > 0 && (
<div className="rounded-2xl border border-amber-300/25 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Only {preview.tagsToAdd.length} tag{preview.tagsToAdd.length === 1 ? '' : 's'} can be added because the limit is {maxTags}. {overflowCount} pasted tag{overflowCount === 1 ? '' : 's'} will be skipped.
</div>
)}
{(duplicateCount > 0 || invalidCount > 0) && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
{duplicateCount > 0 ? `${duplicateCount} duplicate tag${duplicateCount === 1 ? '' : 's'} ignored.` : ''}
{duplicateCount > 0 && invalidCount > 0 ? ' ' : ''}
{invalidCount > 0 ? `${invalidCount} invalid tag${invalidCount === 1 ? '' : 's'} ignored.` : ''}
</div>
)}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
<div className="max-h-56 overflow-auto rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-wrap gap-2">
{preview.tagsToAdd.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-100"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
ref={cancelButtonRef}
type="button"
onClick={() => onClose?.()}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm?.()}
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/40"
>
Add tags
</button>
</div>
</div>
</div>,
document.body,
)
}
export default function TagInput({
value,
onChange,
@@ -268,6 +423,7 @@ export default function TagInput({
const [searchError, setSearchError] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [pastePreview, setPastePreview] = useState(null)
const queryCacheRef = useRef(new Map())
const abortControllerRef = useRef(null)
@@ -304,23 +460,38 @@ export default function TagInput({
}, [selectedTags, updateTags])
const applyPastedTags = useCallback((rawText) => {
const parts = parseTagList(rawText)
if (parts.length === 0) return
const preview = analyzePastedTags(rawText, selectedTags, minLength, maxLength, maxTags)
let next = [...selectedTags]
for (const part of parts) {
const normalized = normalizeTag(part)
const validation = validateTag(normalized, next, minLength, maxLength, maxTags)
if (!validation) {
next.push(normalized)
if (preview.parsedCount === 0) return false
if (preview.tagsToAdd.length === 0) {
if (selectedTags.length >= maxTags || preview.skippedOverflow.length > 0) {
setError('Max tags reached')
} else {
setError('No new tags found in pasted text')
}
return false
}
next = Array.from(new Set(next))
updateTags(next)
setError('')
setIsOpen(false)
setHighlightedIndex(-1)
setPastePreview(preview)
return true
}, [selectedTags, minLength, maxLength, maxTags])
const closePastePreview = useCallback(() => {
setPastePreview(null)
}, [])
const confirmPastePreview = useCallback(() => {
if (!pastePreview) return
updateTags(pastePreview.nextTags)
setInputValue('')
setError('')
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
setPastePreview(null)
}, [pastePreview, updateTags])
const runSearch = useCallback(async (query) => {
const normalizedQuery = normalizeTag(query)
@@ -452,7 +623,10 @@ export default function TagInput({
const handlePaste = useCallback((event) => {
const raw = event.clipboardData?.getData('text')
if (!raw || !raw.includes(',')) return
if (!raw) return
const parts = parseTagList(raw)
if (parts.length <= 1) return
event.preventDefault()
applyPastedTags(raw)
@@ -465,43 +639,53 @@ export default function TagInput({
}, [inputValue, runSearch])
return (
<div className="flex h-full flex-col gap-3" data-testid="tag-input-root">
<TagPillList tags={selectedTags} onRemove={removeTag} disabled={disabled} />
<>
<div className="flex h-full flex-col gap-3" data-testid="tag-input-root">
<TagPillList tags={selectedTags} onRemove={removeTag} disabled={disabled} />
<SearchInput
inputValue={inputValue}
onInputChange={(next) => {
setInputValue(next)
setError('')
}}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
expanded={isOpen}
listboxId={listboxId}
placeholder={placeholder}
<SearchInput
inputValue={inputValue}
onInputChange={(next) => {
setInputValue(next)
setError('')
}}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
expanded={isOpen}
listboxId={listboxId}
placeholder={placeholder}
/>
<SuggestionDropdown
isOpen={isOpen}
loading={loading}
error={searchError}
suggestions={suggestions}
highlightedIndex={highlightedIndex}
onSelect={addTag}
query={inputValue.trim()}
listboxId={listboxId}
/>
<SuggestedTagsPanel
items={aiSuggestedItems}
selectedTags={selectedTags}
onAdd={addTag}
disabled={disabled}
/>
<StatusHints error={error} count={selectedTags.length} maxTags={maxTags} />
</div>
<PastedTagsDialog
open={Boolean(pastePreview)}
preview={pastePreview}
maxTags={maxTags}
onClose={closePastePreview}
onConfirm={confirmPastePreview}
/>
<SuggestionDropdown
isOpen={isOpen}
loading={loading}
error={searchError}
suggestions={suggestions}
highlightedIndex={highlightedIndex}
onSelect={addTag}
query={inputValue.trim()}
listboxId={listboxId}
/>
<SuggestedTagsPanel
items={aiSuggestedItems}
selectedTags={selectedTags}
onAdd={addTag}
disabled={disabled}
/>
<StatusHints error={error} count={selectedTags.length} maxTags={maxTags} />
</div>
</>
)
}

View File

@@ -85,16 +85,37 @@ describe('TagInput', () => {
})
})
it('supports comma-separated paste', async () => {
it('shows a confirmation dialog before adding comma-separated pasted tags', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.click(input)
await userEvent.paste('art, city, night')
expect(screen.getByText('art')).not.toBeNull()
expect(screen.getByText('city')).not.toBeNull()
expect(screen.getByText('night')).not.toBeNull()
expect(screen.getByRole('dialog')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag art' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag art' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag city' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag night' })).not.toBeNull()
})
it('warns when pasted tags exceed the max and only adds the allowed tags', async () => {
render(<Harness initial={['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8', 't9', 't10', 't11', 't12', 't13', 't14']} />)
const input = screen.getByLabelText('Tag input')
await userEvent.click(input)
await userEvent.paste('alpha, beta, gamma')
expect(screen.getByText('Only 1 tag can be added because the limit is 15. 2 pasted tags will be skipped.')).not.toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag alpha' })).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag beta' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag gamma' })).toBeNull()
})
it('handles API failure gracefully', async () => {

View File

@@ -13,6 +13,7 @@
* Value format: string[] of tag slugs
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const MAX_TAGS = 15
const DEBOUNCE_MS = 250
@@ -33,6 +34,55 @@ function normalizeSlug(raw) {
.slice(0, MAX_LENGTH)
}
function parseTagList(input) {
if (Array.isArray(input)) return input
if (typeof input !== 'string') return []
return input
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function analyzePastedTags(rawText, selectedSlugs, maxTags) {
const parts = parseTagList(rawText)
const itemsToAdd = []
const skippedDuplicates = []
const skippedInvalid = []
const skippedOverflow = []
for (const part of parts) {
const name = String(part ?? '').trim()
const slug = normalizeSlug(name)
if (!slug || slug.length < MIN_LENGTH || slug.length > MAX_LENGTH) {
skippedInvalid.push(name)
continue
}
if (selectedSlugs.includes(slug) || itemsToAdd.some((item) => item.slug === slug)) {
skippedDuplicates.push(slug)
continue
}
if (selectedSlugs.length + itemsToAdd.length >= maxTags) {
skippedOverflow.push(slug)
continue
}
itemsToAdd.push({ slug, name })
}
return {
parsedCount: parts.length,
itemsToAdd,
nextSlugs: [...selectedSlugs, ...itemsToAdd.map((item) => item.slug)],
skippedDuplicates,
skippedInvalid,
skippedOverflow,
}
}
function toListItem(item) {
if (!item) return null
if (typeof item === 'string') {
@@ -55,7 +105,7 @@ function toListItem(item) {
// ─── sub-components ───────────────────────────────────────────────────────────
function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
function SearchInput({ value, onChange, onKeyDown, onPaste, inputRef, disabled, hint }) {
return (
<div className="relative">
<input
@@ -64,6 +114,7 @@ function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
disabled={disabled}
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-3 pr-9 text-sm text-white placeholder:text-white/40 focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
placeholder={hint || 'Search or add tags…'}
@@ -170,6 +221,117 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
)
}
function PastedTagsDialog({ open, preview, maxTags, onClose, onConfirm }) {
const backdropRef = useRef(null)
const cancelButtonRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const timeoutId = window.setTimeout(() => cancelButtonRef.current?.focus(), 60)
return () => window.clearTimeout(timeoutId)
}, [open])
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || !preview) return null
const duplicateCount = preview.skippedDuplicates.length
const invalidCount = preview.skippedInvalid.length
const overflowCount = preview.skippedOverflow.length
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="tag-picker-paste-title"
className="w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Tag Import</p>
<h3 id="tag-picker-paste-title" className="mt-2 text-lg font-semibold text-white">
Add {preview.itemsToAdd.length} pasted tag{preview.itemsToAdd.length === 1 ? '' : 's'}?
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before applying them.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{overflowCount > 0 && (
<div className="rounded-2xl border border-amber-300/25 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Only {preview.itemsToAdd.length} tag{preview.itemsToAdd.length === 1 ? '' : 's'} can be added because the limit is {maxTags}. {overflowCount} pasted tag{overflowCount === 1 ? '' : 's'} will be skipped.
</div>
)}
{(duplicateCount > 0 || invalidCount > 0) && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
{duplicateCount > 0 ? `${duplicateCount} duplicate tag${duplicateCount === 1 ? '' : 's'} ignored.` : ''}
{duplicateCount > 0 && invalidCount > 0 ? ' ' : ''}
{invalidCount > 0 ? `${invalidCount} invalid tag${invalidCount === 1 ? '' : 's'} ignored.` : ''}
</div>
)}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
<div className="max-h-56 overflow-auto rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-wrap gap-2">
{preview.itemsToAdd.map((item) => (
<span
key={item.slug}
className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-100"
>
{item.name}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
ref={cancelButtonRef}
type="button"
onClick={() => onClose?.()}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm?.()}
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/40"
>
Add tags
</button>
</div>
</div>
</div>,
document.body,
)
}
// ─── main component ───────────────────────────────────────────────────────────
export default function TagPicker({
@@ -193,6 +355,7 @@ export default function TagPicker({
const [loading, setLoading] = useState(false)
const [fetchError, setFetchError] = useState(false)
const [inputError, setInputError] = useState('')
const [pastePreview, setPastePreview] = useState(null)
// slug → display name (for chips)
const [nameMap, setNameMap] = useState({})
@@ -288,6 +451,20 @@ export default function TagPicker({
onChange?.(selectedSlugs.filter((s) => s !== slug))
}, [selectedSlugs, onChange])
const closePastePreview = useCallback(() => {
setPastePreview(null)
}, [])
const confirmPastePreview = useCallback(() => {
if (!pastePreview) return
updateNameMap(pastePreview.itemsToAdd)
onChange?.(pastePreview.nextSlugs)
setQuery('')
setInputError('')
setPastePreview(null)
}, [onChange, pastePreview, updateNameMap])
// Commit on Enter / comma / Tab
const handleKeyDown = useCallback((e) => {
const commit = e.key === 'Enter' || e.key === ',' || e.key === 'Tab'
@@ -306,6 +483,29 @@ export default function TagPicker({
addTag(candidate, candidate)
}, [query, selectedSlugs, addTag, removeTag])
const handlePaste = useCallback((event) => {
const raw = event.clipboardData?.getData('text')
if (!raw) return
const parts = parseTagList(raw)
if (parts.length <= 1) return
event.preventDefault()
const preview = analyzePastedTags(raw, selectedSlugs, maxTags)
if (preview.itemsToAdd.length === 0) {
if (selectedSlugs.length >= maxTags || preview.skippedOverflow.length > 0) {
setInputError('Maximum tags reached')
} else {
setInputError('No new tags found in pasted text')
}
return
}
setInputError('')
setPastePreview(preview)
}, [maxTags, selectedSlugs])
// Show "Add 'query'" row when the query doesn't exactly match any result
const querySlug = normalizeSlug(query)
const showAddNew = Boolean(
@@ -341,6 +541,7 @@ export default function TagPicker({
value={query}
onChange={(v) => { setQuery(v); setInputError('') }}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
inputRef={inputRef}
disabled={disabled}
hint={placeholder}
@@ -402,11 +603,19 @@ export default function TagPicker({
<span>{selectedSlugs.length}/{maxTags} tags selected</span>
{atMax
? <span className="text-amber-300/80">Maximum tags reached</span>
: <span className="text-white/30">Enter, comma or Tab to add</span>
: <span className="text-white/30">Enter, comma or Tab to add. Paste a comma-separated list to review multiple tags.</span>
}
</div>
{error && <p className="text-xs text-red-300">{error}</p>}
<PastedTagsDialog
open={Boolean(pastePreview)}
preview={pastePreview}
maxTags={maxTags}
onClose={closePastePreview}
onConfirm={confirmPastePreview}
/>
</div>
)
}

View File

@@ -73,4 +73,37 @@ describe('TagPicker', () => {
expect(screen.getByText('High Contrast')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
})
it('shows a confirmation dialog before adding comma-separated pasted tags', async () => {
render(<Harness />)
const input = screen.getByLabelText('Search or add tags')
await userEvent.click(input)
await userEvent.paste('space art, galaxy, robot mascot')
expect(screen.getByRole('dialog')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag space art' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag space art' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag galaxy' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag robot mascot' })).not.toBeNull()
})
it('warns when pasted tags exceed the max and only applies the allowed tags', async () => {
render(<Harness initial={['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8', 't9', 't10', 't11', 't12', 't13', 't14']} />)
const input = screen.getByLabelText('Search or add tags')
await userEvent.click(input)
await userEvent.paste('alpha, beta, gamma')
expect(screen.getByText('Only 1 tag can be added because the limit is 15. 2 pasted tags will be skipped.')).not.toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag alpha' })).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag beta' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag gamma' })).toBeNull()
})
})

View File

@@ -104,6 +104,7 @@ export default function UploadWizard({
onValidationStateChange,
initialDraftId = null,
chunkSize,
chunkRequestTimeoutMs,
contentTypes = [],
suggestedTags = [],
groupOptions = [],
@@ -193,6 +194,7 @@ export default function UploadWizard({
initialDraftId,
metadata,
chunkSize,
chunkRequestTimeoutMs,
onArtworkCreated: (id) => setResolvedArtworkId(id),
onNotice: (notice) => {
if (!notice?.message) return

View File

@@ -5,6 +5,8 @@ import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNot
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024
const DEFAULT_CHUNK_REQUEST_TIMEOUT_MS = 45000
const MIN_CHUNK_SIZE_BYTES = 256 * 1024
const POLL_INTERVAL_MS = 2000
// ─── State machine ───────────────────────────────────────────────────────────
@@ -76,6 +78,16 @@ function toPercent(loaded, total) {
return Math.max(0, Math.min(100, Math.round((loaded / total) * 100)))
}
function formatChunkSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB'
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) === 0 ? 0 : 1)} MB`
return `${Math.max(1, Math.round(bytes / 1024))} KB`
}
function isRequestTooLarge(error) {
return Number(error?.response?.status || 0) === 413
}
function getProcessingValue(payload) {
const direct = String(payload?.processing_state || payload?.status || '').toLowerCase()
return direct || 'processing'
@@ -114,6 +126,7 @@ export default function useUploadMachine({
initialDraftId = null,
metadata,
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
chunkRequestTimeoutMs = DEFAULT_CHUNK_REQUEST_TIMEOUT_MS,
onArtworkCreated,
onNotice,
}) {
@@ -137,6 +150,11 @@ export default function useUploadMachine({
const parsed = Number(chunkSize)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_SIZE_BYTES
})()
const effectiveChunkRequestTimeoutMs = (() => {
const parsed = Number(chunkRequestTimeoutMs)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_REQUEST_TIMEOUT_MS
})()
const adaptiveChunkSizeRef = useRef(effectiveChunkSize)
// ── Controller registry ────────────────────────────────────────────────────
const registerController = useCallback(() => {
@@ -251,7 +269,8 @@ export default function useUploadMachine({
const totalSize = file.size
while (uploadedForFile < totalSize) {
const nextOffset = Math.min(uploadedForFile + effectiveChunkSize, totalSize)
const activeChunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(adaptiveChunkSizeRef.current || effectiveChunkSize))
const nextOffset = Math.min(uploadedForFile + activeChunkSize, totalSize)
const blob = file.slice(uploadedForFile, nextOffset)
const payload = new FormData()
@@ -266,8 +285,22 @@ export default function useUploadMachine({
try {
await window.axios.post(uploadEndpoints.chunk(), payload, {
signal: chunkController.signal,
timeout: effectiveChunkRequestTimeoutMs,
headers: { 'X-Upload-Token': uploadToken },
})
} catch (error) {
if (isRequestTooLarge(error) && activeChunkSize > MIN_CHUNK_SIZE_BYTES) {
const nextChunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Math.floor(activeChunkSize / 2))
if (nextChunkSize < activeChunkSize) {
adaptiveChunkSizeRef.current = nextChunkSize
onNotice?.({
type: 'warning',
message: `Server rejected ${formatChunkSize(activeChunkSize)} chunks. Retrying with ${formatChunkSize(nextChunkSize)} chunks.`,
})
continue
}
}
throw error
} finally {
unregisterController(chunkController)
}
@@ -279,7 +312,7 @@ export default function useUploadMachine({
}
return uploadedBaseBytes + totalSize
}, [effectiveChunkSize, registerController, unregisterController])
}, [effectiveChunkRequestTimeoutMs, effectiveChunkSize, onNotice, registerController, unregisterController])
const cancelUploadSession = useCallback(async (sessionId, uploadToken) => {
if (!sessionId) return

View File

@@ -28,15 +28,21 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
const payload = error?.response?.data || {}
const reason = String(payload?.reason || '').toLowerCase()
const mapped = REASON_MAP[reason]
const errorCode = String(error?.code || '').toUpperCase()
const rawMessage = typeof error?.message === 'string' ? error.message.trim() : ''
const timedOut = errorCode === 'ECONNABORTED' || /timeout/i.test(rawMessage)
const requestTooLarge = status === 413
const type = mapped?.type
? mapped.type
: normalizeType(payload?.type || payload?.level, status >= 500 ? 'error' : 'warning')
: normalizeType(payload?.type || payload?.level, requestTooLarge ? 'warning' : (status >= 500 ? 'error' : 'warning'))
const message =
(requestTooLarge ? 'Server rejected this upload chunk as too large. Retrying with smaller chunks may help, or increase Nginx/PHP upload limits.' : '') ||
(timedOut ? 'Upload request timed out before the server responded. Check Nginx/PHP-FPM body handling and try again.' : '') ||
mapped?.message ||
(typeof payload?.message === 'string' && payload.message.trim()) ||
(typeof error?.message === 'string' && error.message.trim()) ||
rawMessage ||
fallback
return {

View File

@@ -23,8 +23,12 @@
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
$publisherType = (string) (data_get($art, 'publisher.type') ?? ($art->published_as_type ?? ''));
$isGroupPublisher = $publisherType === 'group';
$author = trim((string) (
$art->uname
($isGroupPublisher ? data_get($art, 'publisher.name') : null)
?? $art->uname
?? $art->author_name
?? $art->author
?? ($art->user->name ?? null)
@@ -32,11 +36,13 @@
?? 'Skinbase'
));
$username = trim((string) (
$art->username
?? ($art->user->username ?? null)
?? ''
));
$username = $isGroupPublisher
? ''
: trim((string) (
$art->username
?? ($art->user->username ?? null)
?? ''
));
$rawContentType = trim((string) (
$art->content_type_name
@@ -61,7 +67,14 @@
$avatarUserId = $art->user->id ?? $art->user_id ?? null;
$avatarHash = $art->user->profile->avatar_hash ?? $art->avatar_hash ?? null;
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($avatarUserId ?? 0), $avatarHash, 64);
$avatarUrl = trim((string) (
($isGroupPublisher ? data_get($art, 'publisher.avatar_url') : null)
?? ($art->avatar_url ?? null)
?? ''
));
if ($avatarUrl === '') {
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($avatarUserId ?? 0), $avatarHash, 64);
}
$license = trim((string) ($art->license ?? 'Standard'));
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
@@ -131,7 +144,14 @@
$cardUrl = '#';
}
}
$authorUrl = $username !== '' ? '/@' . strtolower($username) : null;
$authorUrl = trim((string) (
($isGroupPublisher ? data_get($art, 'publisher.profile_url') : null)
?? ($art->profile_url ?? null)
?? ($username !== '' ? '/@' . strtolower($username) : '')
));
if ($authorUrl === '') {
$authorUrl = null;
}
$metaParts = [];
if ($contentType !== '') {

View File

@@ -22,7 +22,7 @@
<body>
<div class="container">
<div class="brand">
<img src="{{ asset('gfx/skinbase_logo.png') }}" alt="Skinbase">
<img src="https://cdn.skinbase.org/images/skinbase_logo_64.webp" alt="Skinbase">
<div>
<div style="font-weight:700;">Skinbase</div>
<div style="font-size:12px;color:#64748b;">New staff application / contact form submission</div>

View File

@@ -325,8 +325,11 @@
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',

View File

@@ -2,7 +2,7 @@
<footer class="border-t border-neutral-800 bg-nova">
<div class="px-6 md:px-10 py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="text-xl font-semibold tracking-wide flex items-center gap-1">
<img src="/gfx/skinbase_logo.png" alt="Skinbase" width="320" height="64" class="h-16 w-auto object-contain">
<img src="https://cdn.skinbase.org/images/skinbase_logo_64.webp" alt="Skinbase" width="320" height="64" class="h-16 w-auto object-contain">
<span class="sr-only">Skinbase</span>
</div>

View File

@@ -18,8 +18,11 @@
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',

View File

@@ -77,6 +77,9 @@
'uname' => $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'published_at' => $art->published_at ?? null,
'content_type_name' => $art->content_type_name ?? '',
'category_name' => $art->category_name ?? '',

View File

@@ -157,8 +157,11 @@
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',

View File

@@ -52,8 +52,11 @@
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',

View File

@@ -21,8 +21,11 @@
'thumb_url' => $art->thumb_url ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'width' => $art->width ?? null,

View File

@@ -411,7 +411,7 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->nam
->name('processing-status');
Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk'])
->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
->middleware(['throttle:uploads-chunk'])
->name('chunk');
Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish'])

View File

@@ -95,6 +95,32 @@ Schedule::command('posts:publish-scheduled')
->name('publish-scheduled-posts')
->withoutOverlapping();
// ── Scheduled content publishing ──────────────────────────────────────────────
// These must live in routes/console.php for Laravel 11's active scheduler.
Schedule::command('artworks:publish-scheduled')
->everyMinute()
->name('publish-scheduled-artworks')
->withoutOverlapping(2)
->runInBackground();
Schedule::command('news:publish-scheduled')
->everyMinute()
->name('publish-scheduled-news')
->withoutOverlapping(2)
->runInBackground();
Schedule::command('nova-cards:publish-scheduled')
->everyMinute()
->name('publish-scheduled-nova-cards')
->withoutOverlapping(2)
->runInBackground();
Schedule::command('collections:sync-lifecycle')
->everyTenMinutes()
->name('sync-collection-lifecycle')
->withoutOverlapping()
->runInBackground();
// ── Feed 2.0: Trending Cache Warm-up ─────────────────────────────────────────
// Warm the post trending cache every 2 minutes (complements the 2-min TTL).
Schedule::command('posts:warm-trending')
@@ -111,6 +137,20 @@ Schedule::command('nova:recalculate-rankings --sync-rank-scores')
->withoutOverlapping()
->runInBackground();
// ── Rising Engine (Heat / Momentum) ───────────────────────────────────────────
// Snapshot current totals each hour, then recalculate heat every 15 minutes.
Schedule::command('nova:metrics-snapshot-hourly')
->hourly()
->name('metrics-snapshot-hourly')
->withoutOverlapping()
->runInBackground();
Schedule::command('nova:recalculate-heat')
->everyFifteenMinutes()
->name('recalculate-heat')
->withoutOverlapping()
->runInBackground();
Schedule::command('forum:ai-scan')
->everyTenMinutes()
->name('forum-ai-scan')

View File

@@ -774,6 +774,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
'filesCdnUrl' => config('cdn.files_url'),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
'feature_flags' => [
'uploads_v2' => (bool) config('features.uploads_v2', false),
],
@@ -829,6 +830,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
'filesCdnUrl' => config('cdn.files_url'),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
'feature_flags' => [
'uploads_v2' => (bool) config('features.uploads_v2', false),
],

View File

@@ -17,12 +17,15 @@ run_local_build=1
run_remote_migrations=1
run_db_sync=0
run_meilisearch_setup=0
auto_detect_meilisearch=1
db_sync_source=""
legacy_db_sync_mode=0
force_db_sync=0
skip_maintenance=0
db_sync_confirm_target="${DB_SYNC_CONFIRM_TARGET:-}"
db_sync_confirm_phrase="${DB_SYNC_CONFIRM_PHRASE:-}"
meilisearch_models_csv=""
readonly all_meilisearch_models_csv='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message'
usage() {
cat <<'EOF'
@@ -38,7 +41,8 @@ Options:
Must equal 'replace production db from local' when running non-interactively.
--with-db Legacy alias for --with-db-from=local.
--force-db-sync Legacy extra confirmation flag for --with-db.
--with-meilisearch Sync index settings then reimport all searchable models.
--with-meilisearch Force Meilisearch settings sync and reimport all searchable models.
--skip-meilisearch Skip Meilisearch refresh, including auto-detected refreshes.
--no-maintenance Skip php artisan down/up during deploy.
--help Show this help.
@@ -120,6 +124,97 @@ run_frontend_build() {
)
}
build_rsync_args() {
rsync_args=(
-rlvz
--no-perms
--no-times
--omit-dir-times
--delete
--delete-delay
--exclude ".phpintel/"
--exclude "bootstrap/cache/"
--exclude ".env"
--exclude "public/hot"
--exclude "node_modules"
--exclude "public/files/"
--exclude "resources/lang/"
--exclude "storage/"
--exclude ".git/"
--exclude ".cursor/"
--exclude ".venv/"
--exclude "/oldSite"
--exclude "/vendor"
-e "$ssh_bin"
)
}
collect_sync_changed_files() {
local itemized
if ! itemized="$($rsync_bin "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:$remote_folder/" 2>/dev/null)"; then
return 1
fi
printf '%s\n' "$itemized" | awk '
/^deleting / {
sub(/^deleting /, "", $0)
if ($0 !~ /\/$/) print
next
}
/^[<>ch.*][^ ]* / {
path = $0
sub(/^[^ ]+ /, "", path)
if (path !~ /\/$/) print path
}
' | sed '/^$/d' | sort -u
}
detect_meilisearch_models_from_sync() {
local changed_files
local file
local force_full=0
local -a models=()
if ! changed_files="$(collect_sync_changed_files)"; then
return 1
fi
[[ -n "$changed_files" ]] || return 1
while IFS= read -r file; do
case "$file" in
config/scout.php|app/Console/Commands/ConfigureMeilisearchIndex.php)
force_full=1
;;
app/Models/Artwork.php)
models+=("App\Models\Artwork")
;;
app/Models/User.php)
models+=("App\Models\User")
;;
app/Models/Group.php)
models+=("App\Models\Group")
;;
app/Models/Post.php)
models+=("App\Models\Post")
;;
app/Models/Message.php)
models+=("App\Models\Message")
;;
esac
done <<< "$changed_files"
if [[ "$force_full" -eq 1 ]]; then
printf '%s\n' "$all_meilisearch_models_csv"
return 0
fi
[[ ${#models[@]} -gt 0 ]] || return 1
printf '%s\n' "$(printf '%s\n' "${models[@]}" | awk '!seen[$0]++' | paste -sd, -)"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build)
@@ -161,6 +256,13 @@ while [[ $# -gt 0 ]]; do
;;
--with-meilisearch)
run_meilisearch_setup=1
auto_detect_meilisearch=0
meilisearch_models_csv="$all_meilisearch_models_csv"
;;
--skip-meilisearch)
run_meilisearch_setup=0
auto_detect_meilisearch=0
meilisearch_models_csv=""
;;
--no-maintenance)
skip_maintenance=1
@@ -202,27 +304,17 @@ if [[ "$run_local_build" -eq 1 ]]; then
run_frontend_build
fi
build_rsync_args
if [[ "$run_meilisearch_setup" -eq 0 && "$auto_detect_meilisearch" -eq 1 ]]; then
if meilisearch_models_csv="$(detect_meilisearch_models_from_sync)"; then
run_meilisearch_setup=1
echo "Detected Meilisearch-relevant changes in this deployment; will refresh indexes for: $meilisearch_models_csv"
fi
fi
echo "Syncing application files to $remote_server..."
"$rsync_bin" -avz \
--delete \
--delete-delay \
--chmod=D755,F644 \
--exclude ".phpintel/" \
--exclude "bootstrap/cache/" \
--exclude ".env" \
--exclude "public/hot" \
--exclude "node_modules" \
--exclude "public/files/" \
--exclude "resources/lang/" \
--exclude "storage/" \
--exclude ".git/" \
--exclude ".cursor/" \
--exclude ".venv/" \
--exclude "/oldSite" \
--exclude "/vendor" \
-e ssh \
"$local_folder/" \
"$remote_server:$remote_folder/"
"$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$remote_folder/"
if [[ "$run_db_sync" -eq 1 ]]; then
echo "Replacing the production database from the local dump..."
@@ -241,11 +333,34 @@ echo "Running remote Composer and Artisan steps..."
RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \
SKIP_MAINTENANCE="$skip_maintenance" \
RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \
MEILISEARCH_MODELS_CSV="$(printf '%q' "$meilisearch_models_csv")" \
'bash -s' <<'EOF'
set -euo pipefail
cd "$REMOTE_FOLDER"
ensure_php_runtime_dir() {
local target_dir="$1"
if command -v sudo >/dev/null 2>&1; then
if [[ ! -d "$target_dir" ]]; then
mkdir -p "$target_dir"
fi
chown -R skinbase:skinbase "$target_dir"
chmod 770 "$target_dir"
return
fi
if [[ ! -d "$target_dir" ]]; then
mkdir -p "$target_dir"
fi
chown -R skinbase:skinbase "$target_dir"
chmod 770 "$target_dir"
}
ensure_php_runtime_dir "$REMOTE_FOLDER/var/php-tmp"
ensure_php_runtime_dir "$REMOTE_FOLDER/var/php-sessions"
bring_app_up() {
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
"$PHP_BIN" artisan up >/dev/null 2>&1 || true
@@ -270,12 +385,18 @@ fi
"$PHP_BIN" artisan queue:restart || true
if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then
if [[ -z "${MEILISEARCH_MODELS_CSV:-}" ]]; then
MEILISEARCH_MODELS_CSV='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message'
fi
IFS=',' read -r -a meilisearch_models <<< "$MEILISEARCH_MODELS_CSV"
echo "Importing searchable models into Meilisearch (auto-creates indexes)..."
"$PHP_BIN" artisan scout:import "App\\Models\\Artwork"
"$PHP_BIN" artisan scout:import "App\\Models\\User"
"$PHP_BIN" artisan scout:import "App\\Models\\Group"
"$PHP_BIN" artisan scout:import "App\\Models\\Post"
"$PHP_BIN" artisan scout:import "App\\Models\\Message"
for model in "${meilisearch_models[@]}"; do
[[ -n "$model" ]] || continue
echo " -> $model"
"$PHP_BIN" artisan scout:import "$model"
done
echo "Syncing Meilisearch index settings..."
"$PHP_BIN" artisan scout:sync-index-settings
echo "Meilisearch setup complete."

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\Artwork;
use App\Models\ArtworkMetricSnapshotHourly;
use Illuminate\Support\Facades\Cache;
beforeEach(function (): void {
Cache::flush();
});
it('GET /rss/discover/rising returns 200', function (): void {
$this->get('/rss/discover/rising')
->assertOk();
});
it('uses the low-signal fallback ordering in the RSS rising feed', function (): void {
$olderActiveArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'RSS Older Active Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(3),
'created_at' => now()->subDays(3),
]));
$newerQuietArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'RSS Newer Quiet Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'created_at' => now()->subHour(),
]));
$previousHour = now()->startOfHour()->subHour();
$currentHour = now()->startOfHour();
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $olderActiveArtwork->id,
'bucket_hour' => $previousHour,
'views_count' => 10,
'downloads_count' => 0,
'favourites_count' => 0,
'comments_count' => 0,
'shares_count' => 0,
]);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $olderActiveArtwork->id,
'bucket_hour' => $currentHour,
'views_count' => 45,
'downloads_count' => 2,
'favourites_count' => 1,
'comments_count' => 0,
'shares_count' => 0,
]);
$response = $this->get('/rss/discover/rising');
$response->assertOk();
$response->assertSeeInOrder([
'RSS Older Active Artwork',
'RSS Newer Quiet Artwork',
], false);
expect($newerQuietArtwork->id)->not->toBe($olderActiveArtwork->id);
});

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -40,3 +41,25 @@ it('home page still renders with rising section data', function () {
$this->get('/')
->assertStatus(200);
});
it('uses the low-signal fallback ordering when rising search results have no momentum', function () {
$olderArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Older Fallback Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(4),
'created_at' => now()->subDays(4),
]));
$newerArtwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
'title' => 'Newer Fallback Artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'created_at' => now()->subHour(),
]));
$this->get('/discover/rising')
->assertStatus(200)
->assertSeeInOrder(['Newer Fallback Artwork', 'Older Fallback Artwork'], false);
});

View File

@@ -3,7 +3,6 @@
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\ArtworkMetricSnapshotHourly;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Helper: create an artwork row without triggering observers (avoids GREATEST() SQLite issue).
@@ -137,6 +136,92 @@ it('computes heat_score from snapshot deltas', function () {
expect((int) $stat->shares_1h)->toBe(1); // 1 - 0
});
it('smooths heat_score over a wider lookback window while keeping 1h counters exact', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subHours(10),
]);
ArtworkStats::upsert([
['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
], ['artwork_id']);
$sixHoursAgo = now()->startOfHour()->subHours(6);
$prevHour = now()->startOfHour()->subHour();
$currentHour = now()->startOfHour();
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $sixHoursAgo,
'views_count' => 10,
'downloads_count' => 0,
'favourites_count' => 0,
'comments_count' => 0,
'shares_count' => 0,
]);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $prevHour,
'views_count' => 30,
'downloads_count' => 1,
'favourites_count' => 0,
'comments_count' => 0,
'shares_count' => 0,
]);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $currentHour,
'views_count' => 30,
'downloads_count' => 1,
'favourites_count' => 0,
'comments_count' => 0,
'shares_count' => 0,
]);
$this->artisan('nova:recalculate-heat --lookback-hours=6')
->assertSuccessful();
$stat = ArtworkStats::where('artwork_id', $artwork->id)->first();
expect((float) $stat->heat_score)->toBeGreaterThan(0);
expect((int) $stat->views_1h)->toBe(0);
expect((int) $stat->downloads_1h)->toBe(0);
});
it('does not assign heat from a single snapshot without a baseline', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subHours(5),
]);
ArtworkStats::upsert([
['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
], ['artwork_id']);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => now()->startOfHour(),
'views_count' => 300,
'downloads_count' => 25,
'favourites_count' => 4,
'comments_count' => 1,
'shares_count' => 0,
]);
$this->artisan('nova:recalculate-heat --lookback-hours=24')
->assertSuccessful();
$stat = ArtworkStats::where('artwork_id', $artwork->id)->first();
expect((float) $stat->heat_score)->toBe(0.0);
expect((int) $stat->views_1h)->toBe(300);
expect((int) $stat->downloads_1h)->toBe(25);
});
it('handles negative deltas gracefully by clamping to zero', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,