minor fixes
@@ -78,6 +78,12 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
|||||||
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
|
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
|
||||||
SKINBASE_UPLOADS_V2=false
|
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
|
# Draft abuse prevention controls
|
||||||
SKINBASE_MAX_DRAFTS=10
|
SKINBASE_MAX_DRAFTS=10
|
||||||
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
|
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class ConfigureMeilisearchIndex extends Command
|
|||||||
*/
|
*/
|
||||||
private const SORTABLE_ATTRIBUTES = [
|
private const SORTABLE_ATTRIBUTES = [
|
||||||
'created_at',
|
'created_at',
|
||||||
|
'published_at_ts',
|
||||||
'trending_score_24h',
|
'trending_score_24h',
|
||||||
'trending_score_7d',
|
'trending_score_7d',
|
||||||
'favorites_count',
|
'favorites_count',
|
||||||
|
|||||||
419
app/Console/Commands/HealthCheckCommand.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,14 @@ use Throwable;
|
|||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
|
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
|
||||||
|
* php artisan skinbase:import-legacy-artworks --artwork-id=69527
|
||||||
*/
|
*/
|
||||||
class ImportLegacyArtworks extends Command
|
class ImportLegacyArtworks extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'skinbase:import-legacy-artworks
|
protected $signature = 'skinbase:import-legacy-artworks
|
||||||
{--chunk=500 : chunk size for processing}
|
{--chunk=500 : chunk size for processing}
|
||||||
{--limit= : maximum number of legacy rows to import}
|
{--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}
|
{--dry-run : do not persist any changes}
|
||||||
{--legacy-connection=legacy : name of legacy DB connection}
|
{--legacy-connection=legacy : name of legacy DB connection}
|
||||||
{--legacy-table=wallz : legacy artworks table name}
|
{--legacy-table=wallz : legacy artworks table name}
|
||||||
@@ -73,15 +75,28 @@ class ImportLegacyArtworks extends Command
|
|||||||
{
|
{
|
||||||
$chunk = (int) $this->option('chunk');
|
$chunk = (int) $this->option('chunk');
|
||||||
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
|
$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');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$legacyConn = $this->option('legacy-connection');
|
$legacyConn = $this->option('legacy-connection');
|
||||||
$legacyTable = $this->option('legacy-table');
|
$legacyTable = $this->option('legacy-table');
|
||||||
$connectedTable = $this->option('connected-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})");
|
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
|
||||||
|
|
||||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
|
$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;
|
$processed = 0;
|
||||||
|
|
||||||
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
|
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
|
||||||
@@ -277,8 +292,14 @@ class ImportLegacyArtworks extends Command
|
|||||||
return null;
|
return null;
|
||||||
}, 'id');
|
}, '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);
|
$this->info('Import complete. Processed: ' . $processed);
|
||||||
|
|
||||||
return 0;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,22 @@ use Illuminate\Support\Facades\Log;
|
|||||||
* Runs every 10–15 minutes via scheduler.
|
* Runs every 10–15 minutes via scheduler.
|
||||||
*
|
*
|
||||||
* Formula:
|
* Formula:
|
||||||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
* raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||||
* + comments_delta*8 + shares_delta*12
|
* + comments_delta*8 + shares_delta*12) / window_hours)
|
||||||
*
|
*
|
||||||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||||
*
|
*
|
||||||
* heat_score = raw_heat * age_factor
|
* heat_score = raw_heat * age_factor
|
||||||
*
|
*
|
||||||
* Usage: php artisan nova:recalculate-heat
|
* 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
|
class RecalculateHeatCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'nova:recalculate-heat
|
protected $signature = 'nova:recalculate-heat
|
||||||
{--days=60 : Only process artworks created within this many days}
|
{--days=60 : Only process artworks created within this many days}
|
||||||
{--chunk=1000 : Chunk size for DB queries}
|
{--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}';
|
{--dry-run : Compute scores without writing to DB}';
|
||||||
|
|
||||||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||||
@@ -44,31 +45,34 @@ class RecalculateHeatCommand extends Command
|
|||||||
{
|
{
|
||||||
$days = (int) $this->option('days');
|
$days = (int) $this->option('days');
|
||||||
$chunk = (int) $this->option('chunk');
|
$chunk = (int) $this->option('chunk');
|
||||||
|
$lookbackHours = max(1, (int) $this->option('lookback-hours'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$now = now();
|
$now = now();
|
||||||
$currentHour = $now->copy()->startOfHour();
|
$currentHour = $now->copy()->startOfHour();
|
||||||
$prevHour = $currentHour->copy()->subHour();
|
$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;
|
$updatedCount = 0;
|
||||||
$skippedCount = 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')
|
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
|
||||||
->distinct()
|
->distinct()
|
||||||
->pluck('artwork_id');
|
->pluck('artwork_id');
|
||||||
|
|
||||||
if ($artworkIds->isEmpty()) {
|
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;
|
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')
|
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
|
||||||
->whereIn('artwork_id', $artworkIds)
|
->whereIn('artwork_id', $artworkIds)
|
||||||
|
->orderBy('bucket_hour')
|
||||||
->get()
|
->get()
|
||||||
->groupBy('artwork_id');
|
->groupBy('artwork_id');
|
||||||
|
|
||||||
@@ -101,27 +105,57 @@ class RecalculateHeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
$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
|
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||||
if (!$currentSnapshot && !$prevSnapshot) {
|
$baselineSnapshot = $artworkSnapshots
|
||||||
|
->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? ''))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $currentSnapshot) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate deltas
|
// One-hour counters remain explicit fields for dashboards and debugging.
|
||||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
$viewsDelta1h = 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));
|
$downloadsDelta1h = 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));
|
$favouritesDelta1h = 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));
|
$commentsDelta1h = 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));
|
$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
|
// Raw heat
|
||||||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
$rawHeat = (
|
||||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
($viewsDelta * self::WEIGHTS['views'])
|
||||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||||
|
+ ($sharesDelta * self::WEIGHTS['shares'])
|
||||||
|
) / $windowHours;
|
||||||
|
|
||||||
// Age factor: favors newer works
|
// Age factor: favors newer works
|
||||||
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||||||
@@ -134,11 +168,11 @@ class RecalculateHeatCommand extends Command
|
|||||||
'artwork_id' => $artworkId,
|
'artwork_id' => $artworkId,
|
||||||
'heat_score' => round($heatScore, 4),
|
'heat_score' => round($heatScore, 4),
|
||||||
'heat_score_updated_at' => $now,
|
'heat_score_updated_at' => $now,
|
||||||
'views_1h' => $viewsDelta,
|
'views_1h' => $viewsDelta1h,
|
||||||
'downloads_1h' => $downloadsDelta,
|
'downloads_1h' => $downloadsDelta1h,
|
||||||
'favourites_1h' => $favouritesDelta,
|
'favourites_1h' => $favouritesDelta1h,
|
||||||
'comments_1h' => $commentsDelta,
|
'comments_1h' => $commentsDelta1h,
|
||||||
'shares_1h' => $sharesDelta,
|
'shares_1h' => $sharesDelta1h,
|
||||||
];
|
];
|
||||||
|
|
||||||
$updatedCount++;
|
$updatedCount++;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use Carbon\Carbon;
|
|||||||
|
|
||||||
class RepairLegacyWallzUsersCommand extends Command
|
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}
|
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||||
{--legacy-connection=legacy : Legacy database connection name}
|
{--legacy-connection=legacy : Legacy database connection name}
|
||||||
{--legacy-table=wallz : Legacy table to update}
|
{--legacy-table=wallz : Legacy table to update}
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ class DashboardGalleryController extends Controller
|
|||||||
{
|
{
|
||||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
$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) [
|
return (object) [
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
@@ -74,13 +86,18 @@ class DashboardGalleryController extends Controller
|
|||||||
'category_slug' => $primary?->slug ?? '',
|
'category_slug' => $primary?->slug ?? '',
|
||||||
'thumb_url' => $present['url'],
|
'thumb_url' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
'username' => $artwork->user?->username ?? '',
|
'username' => $username,
|
||||||
'avatar_url' => AvatarUrl::forUser(
|
'avatar_url' => $avatarUrl,
|
||||||
(int) ($artwork->user_id ?? 0),
|
'profile_url' => $profileUrl,
|
||||||
$artwork->user?->profile?->avatar_hash ?? null,
|
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||||
64
|
'publisher' => [
|
||||||
),
|
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||||
|
'name' => $displayName,
|
||||||
|
'username' => $username,
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'profile_url' => $profileUrl,
|
||||||
|
],
|
||||||
'published_at' => $artwork->published_at,
|
'published_at' => $artwork->published_at,
|
||||||
'slug' => $artwork->slug ?? '',
|
'slug' => $artwork->slug ?? '',
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ namespace App\Http\Controllers\RSS;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
|
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||||
use App\Services\RSS\RSSFeedBuilder;
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DiscoverFeedController
|
* DiscoverFeedController
|
||||||
@@ -22,7 +25,10 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
*/
|
*/
|
||||||
final class DiscoverFeedController extends Controller
|
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 */
|
/** /rss/discover → redirect to fresh */
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
@@ -77,15 +83,19 @@ final class DiscoverFeedController extends Controller
|
|||||||
public function rising(): Response
|
public function rising(): Response
|
||||||
{
|
{
|
||||||
$feedUrl = url('/rss/discover/rising');
|
$feedUrl = url('/rss/discover/rising');
|
||||||
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
|
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||||
Artwork::public()->published()
|
$artworks = Cache::remember(
|
||||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
"rss:discover:rising.{$windowDays}d",
|
||||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
600,
|
||||||
->orderByDesc('artwork_stats.heat_score')
|
function () use ($windowDays) {
|
||||||
->orderByDesc('artworks.published_at')
|
$artworks = $this->risingArtworks($windowDays);
|
||||||
->select('artworks.*')
|
|
||||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
if ($this->collectionHasNoRisingMomentum($artworks)) {
|
||||||
->get()
|
return $this->risingLowSignalArtworks($windowDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $artworks;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->builder->buildFromArtworks(
|
return $this->builder->buildFromArtworks(
|
||||||
@@ -95,4 +105,76 @@ final class DiscoverFeedController extends Controller
|
|||||||
$artworks,
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,11 +280,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
$group = $artwork->group;
|
||||||
(int) ($artwork->user_id ?? 0),
|
$isGroupPublisher = $group !== null;
|
||||||
$artwork->user?->profile?->avatar_hash ?? null,
|
$avatarUrl = $isGroupPublisher
|
||||||
64
|
? $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) [
|
return (object) [
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
@@ -295,9 +302,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
|||||||
'category_slug' => $primaryCategory->slug ?? '',
|
'category_slug' => $primaryCategory->slug ?? '',
|
||||||
'thumb_url' => $present['url'],
|
'thumb_url' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
'username' => $artwork->user?->username ?? '',
|
'username' => $username,
|
||||||
'avatar_url' => $avatarUrl,
|
'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,
|
'published_at' => $artwork->published_at,
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
'height' => $artwork->height ?? null,
|
'height' => $artwork->height ?? null,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Models\Artwork;
|
|||||||
use App\Services\CommunityActivityService;
|
use App\Services\CommunityActivityService;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
use App\Services\EarlyGrowth\FeedBlender;
|
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
use App\Services\EarlyGrowth\GridFiller;
|
||||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||||
use App\Services\UserSuggestionService;
|
use App\Services\UserSuggestionService;
|
||||||
@@ -33,8 +33,8 @@ final class DiscoverController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkService $artworkService,
|
private readonly ArtworkService $artworkService,
|
||||||
private readonly ArtworkSearchService $searchService,
|
private readonly ArtworkSearchService $searchService,
|
||||||
|
private readonly AdaptiveTimeWindow $timeWindow,
|
||||||
private readonly RecommendationFeedResolver $feedResolver,
|
private readonly RecommendationFeedResolver $feedResolver,
|
||||||
private readonly FeedBlender $feedBlender,
|
|
||||||
private readonly GridFiller $gridFiller,
|
private readonly GridFiller $gridFiller,
|
||||||
private readonly CommunityActivityService $communityActivity,
|
private readonly CommunityActivityService $communityActivity,
|
||||||
private readonly UserSuggestionService $userSuggestions,
|
private readonly UserSuggestionService $userSuggestions,
|
||||||
@@ -45,9 +45,18 @@ final class DiscoverController extends Controller
|
|||||||
public function trending(Request $request)
|
public function trending(Request $request)
|
||||||
{
|
{
|
||||||
$perPage = 24;
|
$perPage = 24;
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||||
$results = $this->searchService->discoverTrending($perPage);
|
|
||||||
$results = $this->gridFiller->fill($results, 0, $page);
|
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);
|
$this->hydrateDiscoverSearchResults($results);
|
||||||
|
|
||||||
return view('web.discover.index', [
|
return view('web.discover.index', [
|
||||||
@@ -64,9 +73,22 @@ final class DiscoverController extends Controller
|
|||||||
public function rising(Request $request)
|
public function rising(Request $request)
|
||||||
{
|
{
|
||||||
$perPage = 24;
|
$perPage = 24;
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||||
$results = $this->searchService->discoverRising($perPage);
|
|
||||||
$results = $this->gridFiller->fill($results, 0, $page);
|
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);
|
$this->hydrateDiscoverSearchResults($results);
|
||||||
|
|
||||||
return view('web.discover.index', [
|
return view('web.discover.index', [
|
||||||
@@ -83,11 +105,12 @@ final class DiscoverController extends Controller
|
|||||||
public function fresh(Request $request)
|
public function fresh(Request $request)
|
||||||
{
|
{
|
||||||
$perPage = 24;
|
$perPage = 24;
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
|
||||||
$results = $this->searchService->discoverFresh($perPage);
|
$results = $this->searchService->discoverFresh($perPage);
|
||||||
// EGS: blend fresh feed with curated + spotlight on page 1
|
|
||||||
$results = $this->feedBlender->blend($results, $perPage, $page);
|
if ($this->paginatorIsEmpty($results)) {
|
||||||
$results = $this->gridFiller->fill($results, 0, $page);
|
$results = $this->fallbackFreshFromDatabase($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
$this->hydrateDiscoverSearchResults($results);
|
$this->hydrateDiscoverSearchResults($results);
|
||||||
|
|
||||||
return view('web.discover.index', [
|
return view('web.discover.index', [
|
||||||
@@ -351,6 +374,152 @@ final class DiscoverController extends Controller
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── 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
|
private function hydrateDiscoverSearchResults($paginator): void
|
||||||
{
|
{
|
||||||
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
|
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
|
||||||
@@ -377,6 +546,7 @@ final class DiscoverController extends Controller
|
|||||||
->with([
|
->with([
|
||||||
'user:id,name,username',
|
'user:id,name,username',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
|
'group:id,name,slug,avatar_path',
|
||||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||||
])
|
])
|
||||||
->get()
|
->get()
|
||||||
@@ -398,9 +568,12 @@ final class DiscoverController extends Controller
|
|||||||
'category_slug' => $item->category_slug ?? '',
|
'category_slug' => $item->category_slug ?? '',
|
||||||
'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null,
|
'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null,
|
||||||
'thumb_srcset' => $item->thumb_srcset ?? null,
|
'thumb_srcset' => $item->thumb_srcset ?? null,
|
||||||
'uname' => $item->author ?? $item->uname ?? 'Skinbase',
|
'uname' => $item->author_name ?? $item->author ?? $item->uname ?? 'Skinbase',
|
||||||
'username' => $item->username ?? '',
|
'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),
|
'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,
|
'published_at' => $item->published_at ?? null,
|
||||||
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
|
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
|
||||||
'height' => isset($item->height) && $item->height ? (int) $item->height : 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();
|
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
$group = $artwork->group;
|
||||||
(int) ($artwork->user_id ?? 0),
|
$isGroupPublisher = $group !== null;
|
||||||
$artwork->user?->profile?->avatar_hash ?? null,
|
$avatarUrl = $isGroupPublisher
|
||||||
64
|
? $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) [
|
return (object) [
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
@@ -429,8 +609,18 @@ final class DiscoverController extends Controller
|
|||||||
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
|
||||||
'thumb_url' => $present['url'],
|
'thumb_url' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
|
'username' => $username,
|
||||||
'avatar_url' => $avatarUrl,
|
'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,
|
'published_at' => $artwork->published_at,
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
'height' => $artwork->height ?? null,
|
'height' => $artwork->height ?? null,
|
||||||
|
|||||||
@@ -276,11 +276,18 @@ final class ExploreController extends Controller
|
|||||||
{
|
{
|
||||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
$group = $artwork->group;
|
||||||
(int) ($artwork->user_id ?? 0),
|
$isGroupPublisher = $group !== null;
|
||||||
$artwork->user?->profile?->avatar_hash ?? null,
|
$avatarUrl = $isGroupPublisher
|
||||||
64
|
? $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) [
|
return (object) [
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
@@ -291,9 +298,18 @@ final class ExploreController extends Controller
|
|||||||
'category_slug' => $primary->slug ?? '',
|
'category_slug' => $primary->slug ?? '',
|
||||||
'thumb_url' => $present['url'],
|
'thumb_url' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
'username' => $artwork->user?->username ?? '',
|
'username' => $username,
|
||||||
'avatar_url' => $avatarUrl,
|
'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,
|
'published_at' => $artwork->published_at,
|
||||||
'slug' => $artwork->slug ?? '',
|
'slug' => $artwork->slug ?? '',
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
|
|||||||
@@ -290,11 +290,18 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
{
|
{
|
||||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||||
$avatarUrl = AvatarUrl::forUser(
|
$group = $artwork->group;
|
||||||
(int) ($artwork->user_id ?? 0),
|
$isGroupPublisher = $group !== null;
|
||||||
$artwork->user?->profile?->avatar_hash ?? null,
|
$avatarUrl = $isGroupPublisher
|
||||||
64
|
? $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) [
|
return (object) [
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
@@ -305,9 +312,18 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
'category_slug' => $primary?->slug ?? '',
|
'category_slug' => $primary?->slug ?? '',
|
||||||
'thumb_url' => $present['url'],
|
'thumb_url' => $present['url'],
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
'uname' => $displayName,
|
||||||
'username' => $artwork->user?->username ?? '',
|
'username' => $username,
|
||||||
'avatar_url' => $avatarUrl,
|
'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,
|
'published_at' => $artwork->published_at,
|
||||||
'slug' => $artwork->slug ?? '',
|
'slug' => $artwork->slug ?? '',
|
||||||
'width' => $artwork->width ?? null,
|
'width' => $artwork->width ?? null,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
|
||||||
use App\Services\Tags\TagDiscoveryService;
|
use App\Services\Tags\TagDiscoveryService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -18,7 +17,6 @@ final class TagController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ArtworkSearchService $search,
|
private readonly ArtworkSearchService $search,
|
||||||
private readonly GridFiller $gridFiller,
|
|
||||||
private readonly TagDiscoveryService $tagDiscovery,
|
private readonly TagDiscoveryService $tagDiscovery,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -45,29 +43,10 @@ final class TagController extends Controller
|
|||||||
|
|
||||||
public function show(Tag $tag, Request $request): View
|
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);
|
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||||
|
|
||||||
// Convert sort param to Meili sort expression
|
$artworks = $this->search->byTag($tag->slug, $perPage, $sort);
|
||||||
$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);
|
|
||||||
|
|
||||||
// Eager-load relations used by the gallery presenter and thumbnails.
|
// Eager-load relations used by the gallery presenter and thumbnails.
|
||||||
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ namespace App\Http\Requests\Uploads;
|
|||||||
use App\Repositories\Uploads\UploadSessionRepository;
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
use App\Services\Uploads\UploadTokenService;
|
use App\Services\Uploads\UploadTokenService;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
final class UploadChunkRequest extends FormRequest
|
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
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
@@ -79,6 +93,63 @@ final class UploadChunkRequest extends FormRequest
|
|||||||
throw new NotFoundHttpException();
|
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
|
private function logUnauthorized(string $reason): void
|
||||||
{
|
{
|
||||||
logger()->warning('Upload chunk unauthorized access', [
|
logger()->warning('Upload chunk unauthorized access', [
|
||||||
|
|||||||
@@ -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');
|
$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 [
|
return [
|
||||||
'id' => $artId,
|
'id' => $artId,
|
||||||
'slug' => $slugVal,
|
'slug' => $slugVal,
|
||||||
@@ -71,12 +87,12 @@ class ArtworkListResource extends JsonResource
|
|||||||
'height' => $get('height'),
|
'height' => $get('height'),
|
||||||
],
|
],
|
||||||
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
|
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
|
||||||
'author' => $this->whenLoaded('user', function () use ($decode) {
|
'author' => $publisher,
|
||||||
return [
|
'publisher' => $publisher,
|
||||||
'name' => $decode($this->user->name ?? null),
|
'author_name' => $publisher['name'] ?? '',
|
||||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
'avatar_url' => $publisher['avatar_url'] ?? null,
|
||||||
];
|
'profile_url' => $publisher['profile_url'] ?? null,
|
||||||
}),
|
'published_as_type' => $publisher['type'] ?? null,
|
||||||
'category' => $primaryCategory ? [
|
'category' => $primaryCategory ? [
|
||||||
'slug' => $primaryCategory->slug ?? null,
|
'slug' => $primaryCategory->slug ?? null,
|
||||||
'name' => $decode($primaryCategory->name ?? null),
|
'name' => $decode($primaryCategory->name ?? null),
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ class Artwork extends Model
|
|||||||
|
|
||||||
$stat = $this->stats;
|
$stat = $this->stats;
|
||||||
$awardStat = $this->awardStat;
|
$awardStat = $this->awardStat;
|
||||||
|
$publishedSortAt = $this->published_at ?? $this->created_at;
|
||||||
|
|
||||||
// Orientation derived from pixel dimensions
|
// Orientation derived from pixel dimensions
|
||||||
$orientation = 'square';
|
$orientation = 'square';
|
||||||
@@ -380,7 +381,8 @@ class Artwork extends Model
|
|||||||
'downloads' => (int) ($stat?->downloads ?? 0),
|
'downloads' => (int) ($stat?->downloads ?? 0),
|
||||||
'likes' => (int) ($stat?->favorites ?? 0),
|
'likes' => (int) ($stat?->favorites ?? 0),
|
||||||
'views' => (int) ($stat?->views ?? 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_public' => (bool) $this->is_public,
|
||||||
'is_approved' => (bool) $this->is_approved,
|
'is_approved' => (bool) $this->is_approved,
|
||||||
// ── Trending / discovery fields ────────────────────────────────────
|
// ── Trending / discovery fields ────────────────────────────────────
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
return $this->buildUploadLimits($request, 'init');
|
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 {
|
RateLimiter::for('uploads-finish', function (Request $request): array {
|
||||||
return $this->buildUploadLimits($request, 'finish');
|
return $this->buildUploadLimits($request, 'finish');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Tag;
|
|||||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
* 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 BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||||
private const CACHE_TTL = 300; // 5 minutes
|
private const CACHE_TTL = 300; // 5 minutes
|
||||||
|
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AdaptiveTimeWindow $timeWindow,
|
private readonly AdaptiveTimeWindow $timeWindow,
|
||||||
@@ -82,22 +84,46 @@ final class ArtworkSearchService
|
|||||||
/**
|
/**
|
||||||
* Load artworks for a tag page, sorted by views + likes descending.
|
* 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();
|
$tag = Tag::where('slug', $slug)->first();
|
||||||
if (! $tag) {
|
if (! $tag) {
|
||||||
return $this->emptyPaginator($perPage);
|
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 Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
|
||||||
return Artwork::search('')
|
$query = Artwork::query()
|
||||||
->options([
|
->public()
|
||||||
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
|
->published()
|
||||||
'sort' => ['views:desc', 'likes:desc'],
|
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
|
||||||
])
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
->paginate($perPage);
|
->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().
|
* Used by categoryPageSort() and contentTypePageSort().
|
||||||
*/
|
*/
|
||||||
private const CATEGORY_SORT_FIELDS = [
|
private const CATEGORY_SORT_FIELDS = [
|
||||||
'trending' => ['trending_score_24h:desc', 'created_at:desc'],
|
'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'],
|
||||||
'fresh' => ['created_at:desc'],
|
'fresh' => ['published_at_ts:desc'],
|
||||||
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
||||||
'downloaded' => ['downloads_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. */
|
/** 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
|
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
@@ -245,7 +271,7 @@ final class ArtworkSearchService
|
|||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['created_at:desc'],
|
'sort' => ['published_at_ts:desc'],
|
||||||
])
|
])
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
@@ -294,7 +320,7 @@ final class ArtworkSearchService
|
|||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
'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);
|
->paginate($perPage);
|
||||||
});
|
});
|
||||||
@@ -310,7 +336,7 @@ final class ArtworkSearchService
|
|||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => self::BASE_FILTER,
|
'filter' => self::BASE_FILTER,
|
||||||
'sort' => ['created_at:desc'],
|
'sort' => ['published_at_ts:desc'],
|
||||||
])
|
])
|
||||||
->paginate($perPage);
|
->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.
|
* Used for personalized "Fresh in your favourite categories" section.
|
||||||
*
|
*
|
||||||
* @param string[] $categorySlugs
|
* @param string[] $categorySlugs
|
||||||
@@ -400,7 +426,7 @@ final class ArtworkSearchService
|
|||||||
return Artwork::search('')
|
return Artwork::search('')
|
||||||
->options([
|
->options([
|
||||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||||
'sort' => ['created_at:desc'],
|
'sort' => ['published_at_ts:desc'],
|
||||||
])
|
])
|
||||||
->paginate($limit);
|
->paginate($limit);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,24 @@ class ArtworkService
|
|||||||
{
|
{
|
||||||
protected int $cacheTtl = 3600; // seconds
|
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.
|
* Shared browse query used by /browse, content-type pages, and category pages.
|
||||||
*/
|
*/
|
||||||
@@ -30,13 +48,7 @@ class ArtworkService
|
|||||||
{
|
{
|
||||||
$query = Artwork::public()
|
$query = Artwork::public()
|
||||||
->published()
|
->published()
|
||||||
->with([
|
->with($this->browseRelations());
|
||||||
'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']);
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
$normalizedSort = strtolower(trim($sort));
|
$normalizedSort = strtolower(trim($sort));
|
||||||
if ($normalizedSort === 'oldest') {
|
if ($normalizedSort === 'oldest') {
|
||||||
@@ -110,6 +122,7 @@ class ArtworkService
|
|||||||
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
|
||||||
{
|
{
|
||||||
$query = Artwork::public()->published()
|
$query = Artwork::public()->published()
|
||||||
|
->with($this->browseRelations())
|
||||||
->whereHas('categories', function ($q) use ($category) {
|
->whereHas('categories', function ($q) use ($category) {
|
||||||
$q->where('categories.id', $category->id);
|
$q->where('categories.id', $category->id);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final class AdaptiveTimeWindow
|
|||||||
{
|
{
|
||||||
$ttl = (int) config('early_growth.cache_ttl.time_window', 600);
|
$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()
|
$count = Artwork::query()
|
||||||
->where('is_public', true)
|
->where('is_public', true)
|
||||||
->where('is_approved', true)
|
->where('is_approved', true)
|
||||||
@@ -72,7 +72,7 @@ final class AdaptiveTimeWindow
|
|||||||
->where('published_at', '>=', now()->subDays(7))
|
->where('published_at', '>=', now()->subDays(7))
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
return round($count / 7, 2);
|
return (float) round($count / 7, 2);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ final class HomepageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getHomepageGroups(?\App\Models\User $viewer = null): array
|
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);
|
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
|
||||||
$spotlight = $featured[0] ?? null;
|
$spotlight = $featured[0] ?? null;
|
||||||
@@ -314,6 +323,10 @@ final class HomepageService
|
|||||||
return $this->getRisingFromDb($limit);
|
return $this->getRisingFromDb($limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
|
||||||
|
return $this->getRisingLowSignalFromDb($limit);
|
||||||
|
}
|
||||||
|
|
||||||
return $items
|
return $items
|
||||||
->map(fn ($a) => $this->serializeArtwork($a))
|
->map(fn ($a) => $this->serializeArtwork($a))
|
||||||
->values()
|
->values()
|
||||||
@@ -348,6 +361,26 @@ final class HomepageService
|
|||||||
->all();
|
->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`.
|
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||||||
*
|
*
|
||||||
@@ -466,26 +499,38 @@ final class HomepageService
|
|||||||
try {
|
try {
|
||||||
$since = now()->subWeek();
|
$since = now()->subWeek();
|
||||||
|
|
||||||
$rows = DB::table('artworks')
|
$weeklyUploads = Artwork::query()
|
||||||
->join('users as u', 'u.id', '=', 'artworks.user_id')
|
->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('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||||
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
|
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
|
||||||
|
$join->on('weekly_uploads.user_id', '=', 'u.id');
|
||||||
|
})
|
||||||
->select(
|
->select(
|
||||||
'u.id',
|
'u.id',
|
||||||
'u.name',
|
'u.name',
|
||||||
'u.username',
|
'u.username',
|
||||||
'up.avatar_hash',
|
'up.avatar_hash',
|
||||||
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
|
DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
|
||||||
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
|
DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
|
||||||
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
|
DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
|
||||||
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
|
DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
|
||||||
)
|
)
|
||||||
->where('artworks.is_public', true)
|
->whereNull('u.deleted_at')
|
||||||
->where('artworks.is_approved', true)
|
->where('u.is_active', true)
|
||||||
->whereNull('artworks.deleted_at')
|
->where(function ($query): void {
|
||||||
->whereNotNull('artworks.published_at')
|
$query->where('us.uploads_count', '>', 0)
|
||||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
|
->orWhere('weekly_uploads.weekly_uploads', '>', 0);
|
||||||
|
})
|
||||||
->orderByDesc('weekly_uploads')
|
->orderByDesc('weekly_uploads')
|
||||||
->orderByDesc('total_awards')
|
->orderByDesc('total_awards')
|
||||||
->orderByDesc('total_views')
|
->orderByDesc('total_views')
|
||||||
@@ -494,18 +539,23 @@ final class HomepageService
|
|||||||
|
|
||||||
$userIds = $rows->pluck('id')->all();
|
$userIds = $rows->pluck('id')->all();
|
||||||
|
|
||||||
// Pick one random artwork thumbnail per creator for the card background.
|
$latestArtworkIds = Artwork::public()
|
||||||
$thumbsByUser = Artwork::public()
|
|
||||||
->published()
|
->published()
|
||||||
->whereIn('user_id', $userIds)
|
->whereIn('user_id', $userIds)
|
||||||
->whereNotNull('hash')
|
->whereNotNull('hash')
|
||||||
->whereNotNull('thumb_ext')
|
->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'])
|
->get(['id', 'user_id', 'hash', 'thumb_ext'])
|
||||||
->groupBy('user_id');
|
->keyBy('user_id');
|
||||||
|
|
||||||
return $rows->map(function ($u) use ($thumbsByUser) {
|
return $rows->map(function ($u) use ($thumbsByUser) {
|
||||||
$artworkForBg = $thumbsByUser->get($u->id)?->first();
|
$artworkForBg = $thumbsByUser->get($u->id);
|
||||||
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
|
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -792,6 +842,37 @@ final class HomepageService
|
|||||||
return $artworks;
|
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
|
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
|
||||||
{
|
{
|
||||||
$thumbMd = $artwork->thumbUrl('md');
|
$thumbMd = $artwork->thumbUrl('md');
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ final class CreatorStudioContentService
|
|||||||
{
|
{
|
||||||
$now = Carbon::now();
|
$now = Carbon::now();
|
||||||
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
|
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
|
||||||
|
$status = (string) ($item['status'] ?? '');
|
||||||
$isDraft = ($item['status'] ?? null) === 'draft';
|
$isDraft = ($item['status'] ?? null) === 'draft';
|
||||||
$missing = [];
|
$missing = [];
|
||||||
$score = 0;
|
$score = 0;
|
||||||
@@ -441,6 +442,16 @@ final class CreatorStudioContentService
|
|||||||
default => 'Needs more work',
|
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'] ?? '')) {
|
$workflowActions = match ((string) ($item['module'] ?? '')) {
|
||||||
'artworks' => [
|
'artworks' => [
|
||||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
['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)),
|
'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)),
|
||||||
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
|
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
|
||||||
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
|
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
|
||||||
'readiness' => [
|
'readiness' => $readiness,
|
||||||
'score' => $score,
|
|
||||||
'max' => 4,
|
|
||||||
'label' => $label,
|
|
||||||
'can_publish' => $score >= 3,
|
|
||||||
'missing' => $missing,
|
|
||||||
],
|
|
||||||
'cross_module_actions' => $workflowActions,
|
'cross_module_actions' => $workflowActions,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
|||||||
$draftCount = (clone $baseQuery)
|
$draftCount = (clone $baseQuery)
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->where(function (Builder $query): void {
|
->where(function (Builder $query): void {
|
||||||
$query->where('is_public', false)
|
$query->whereNull('artwork_status')
|
||||||
->orWhere('artwork_status', 'draft');
|
->orWhere('artwork_status', '!=', 'scheduled');
|
||||||
})
|
})
|
||||||
|
->where('is_public', false)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$publishedCount = (clone $baseQuery)
|
$publishedCount = (clone $baseQuery)
|
||||||
@@ -92,16 +93,29 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
|||||||
$query = Artwork::query()
|
$query = Artwork::query()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->where('user_id', $user->id)
|
->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')
|
->orderByDesc('updated_at')
|
||||||
->limit($limit);
|
->limit($limit);
|
||||||
|
|
||||||
if ($bucket === 'drafts') {
|
if ($bucket === 'drafts') {
|
||||||
$query->whereNull('deleted_at')
|
$query->whereNull('deleted_at')
|
||||||
->where(function (Builder $builder): void {
|
->where(function (Builder $builder): void {
|
||||||
$builder->where('is_public', false)
|
$builder->whereNull('artwork_status')
|
||||||
->orWhere('artwork_status', 'draft');
|
->orWhere('artwork_status', '!=', 'scheduled');
|
||||||
});
|
})
|
||||||
|
->where('is_public', false);
|
||||||
} elseif ($bucket === 'scheduled') {
|
} elseif ($bucket === 'scheduled') {
|
||||||
$query->whereNull('deleted_at')
|
$query->whereNull('deleted_at')
|
||||||
->where('artwork_status', 'scheduled');
|
->where('artwork_status', 'scheduled');
|
||||||
@@ -199,7 +213,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
|||||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||||
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
|
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
|
||||||
'schedule_timezone' => $artwork->artwork_timezone,
|
'schedule_timezone' => $artwork->artwork_timezone,
|
||||||
'featured' => false,
|
'featured' => $artwork->features->isNotEmpty(),
|
||||||
'metrics' => [
|
'metrics' => [
|
||||||
'views' => (int) ($stats?->views ?? 0),
|
'views' => (int) ($stats?->views ?? 0),
|
||||||
'appreciation' => (int) ($stats?->favorites ?? 0),
|
'appreciation' => (int) ($stats?->favorites ?? 0),
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ final class StudioBulkActionService
|
|||||||
$query = Artwork::where('user_id', $userId);
|
$query = Artwork::where('user_id', $userId);
|
||||||
if ($action === 'unarchive') {
|
if ($action === 'unarchive') {
|
||||||
$query->onlyTrashed();
|
$query->onlyTrashed();
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$query->withTrashed();
|
||||||
}
|
}
|
||||||
$artworks = $query->whereIn('id', $artworkIds)->get();
|
$artworks = $query->whereIn('id', $artworkIds)->get();
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ return [
|
|||||||
],
|
],
|
||||||
'sortableAttributes' => [
|
'sortableAttributes' => [
|
||||||
'created_at',
|
'created_at',
|
||||||
|
'published_at_ts',
|
||||||
'downloads',
|
'downloads',
|
||||||
'likes',
|
'likes',
|
||||||
'views',
|
'views',
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ return [
|
|||||||
'per_user' => env('UPLOAD_RATE_INIT_USER', 10),
|
'per_user' => env('UPLOAD_RATE_INIT_USER', 10),
|
||||||
'per_ip' => env('UPLOAD_RATE_INIT_IP', 30),
|
'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' => [
|
'finish' => [
|
||||||
'per_user' => env('UPLOAD_RATE_FINISH_USER', 6),
|
'per_user' => env('UPLOAD_RATE_FINISH_USER', 6),
|
||||||
'per_ip' => env('UPLOAD_RATE_FINISH_IP', 12),
|
'per_ip' => env('UPLOAD_RATE_FINISH_IP', 12),
|
||||||
@@ -126,6 +130,7 @@ return [
|
|||||||
'max_bytes' => env('UPLOAD_CHUNK_MAX_BYTES', 5242880),
|
'max_bytes' => env('UPLOAD_CHUNK_MAX_BYTES', 5242880),
|
||||||
'lock_seconds' => env('UPLOAD_CHUNK_LOCK_SECONDS', 10),
|
'lock_seconds' => env('UPLOAD_CHUNK_LOCK_SECONDS', 10),
|
||||||
'lock_wait_seconds' => env('UPLOAD_CHUNK_LOCK_WAIT_SECONDS', 5),
|
'lock_wait_seconds' => env('UPLOAD_CHUNK_LOCK_WAIT_SECONDS', 5),
|
||||||
|
'request_timeout_ms' => env('UPLOAD_CHUNK_REQUEST_TIMEOUT_MS', 45000),
|
||||||
],
|
],
|
||||||
|
|
||||||
'scan' => [
|
'scan' => [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[program:skinbase-queue]
|
[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
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
numprocs=1
|
numprocs=1
|
||||||
autostart=true
|
autostart=true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Group=www-data
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
WorkingDirectory=/var/www/skinbase
|
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
|
StandardOutput=syslog
|
||||||
StandardError=syslog
|
StandardError=syslog
|
||||||
SyslogIdentifier=skinbase-queue
|
SyslogIdentifier=skinbase-queue
|
||||||
|
|||||||
93
docs/Discover/README.md
Normal 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
@@ -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
@@ -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
|
||||||
59
docs/Discover/most-downloaded.md
Normal 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.
|
||||||
52
docs/Discover/on-this-day.md
Normal 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
@@ -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.
|
||||||
63
docs/Discover/today-downloads.md
Normal 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`.
|
||||||
58
docs/Discover/top-rated.md
Normal 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
@@ -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.
|
||||||
BIN
public/gfx/skinbase_logo.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/gfx/skinbase_logo_128.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/gfx/skinbase_logo_256.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/gfx/skinbase_logo_32.webp
Normal file
|
After Width: | Height: | Size: 866 B |
BIN
public/gfx/skinbase_logo_512.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/gfx/skinbase_logo_64.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/gfx/skinbase_logo_96.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
@@ -7,6 +7,7 @@ import Button from '../../components/ui/Button'
|
|||||||
import Modal from '../../components/ui/Modal'
|
import Modal from '../../components/ui/Modal'
|
||||||
import FormField from '../../components/ui/FormField'
|
import FormField from '../../components/ui/FormField'
|
||||||
import Toggle from '../../components/ui/Toggle'
|
import Toggle from '../../components/ui/Toggle'
|
||||||
|
import NovaSelect from '../../components/ui/NovaSelect'
|
||||||
import TagPicker from '../../components/tags/TagPicker'
|
import TagPicker from '../../components/tags/TagPicker'
|
||||||
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
|
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
|
||||||
|
|
||||||
@@ -286,6 +287,29 @@ export default function StudioArtworkEdit() {
|
|||||||
selectedRoot?.name || 'No root category',
|
selectedRoot?.name || 'No root category',
|
||||||
subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null,
|
subCategoryId ? subCategories.find((item) => item.id === subCategoryId)?.name : null,
|
||||||
].filter(Boolean)
|
].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 ───────────────────────────────────────────────────────────────
|
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||||
const handleContentTypeChange = (id) => {
|
const handleContentTypeChange = (id) => {
|
||||||
@@ -1172,37 +1196,60 @@ export default function StudioArtworkEdit() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block">
|
<NovaSelect
|
||||||
<span className="text-sm font-medium text-white/90">Publishing identity</span>
|
label="Publishing identity"
|
||||||
<select
|
value={groupSlug || ''}
|
||||||
value={groupSlug}
|
onChange={(nextValue) => setGroupSlug(String(nextValue || ''))}
|
||||||
onChange={(event) => setGroupSlug(event.target.value)}
|
options={publishingIdentityOptions}
|
||||||
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"
|
searchable={false}
|
||||||
>
|
placeholder="Choose publishing identity"
|
||||||
<option value="">Personal profile</option>
|
error={errors.group?.[0]}
|
||||||
{groupOptions.map((group) => (
|
hint={selectedGroupOption
|
||||||
<option key={group.slug} value={group.slug}>{group.name}</option>
|
? '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.'}
|
||||||
</select>
|
className="mt-2 bg-black/20"
|
||||||
{errors.group?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.group[0]}</p> : null}
|
renderOption={(option) => (
|
||||||
</label>
|
<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 ? (
|
{groupSlug ? (
|
||||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||||
<div>
|
<div>
|
||||||
<label className="block">
|
<NovaSelect
|
||||||
<span className="text-sm font-medium text-white/90">Primary author</span>
|
label="Primary author"
|
||||||
<select
|
value={primaryAuthorUserId || null}
|
||||||
value={primaryAuthorUserId || ''}
|
onChange={(nextValue) => setPrimaryAuthorUserId(nextValue ? Number(nextValue) : null)}
|
||||||
onChange={(event) => setPrimaryAuthorUserId(event.target.value ? Number(event.target.value) : null)}
|
options={primaryAuthorOptions}
|
||||||
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"
|
placeholder="Choose primary author"
|
||||||
>
|
searchable={primaryAuthorOptions.length > 6}
|
||||||
{currentContributorOptions.map((user) => (
|
error={errors.primary_author_user_id?.[0]}
|
||||||
<option key={user.id} value={user.id}>{user.name || user.username}</option>
|
hint="Primary author remains the lead creator shown on the public artwork page."
|
||||||
))}
|
className="mt-2 bg-black/20"
|
||||||
</select>
|
renderOption={(option) => (
|
||||||
</label>
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
{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>}
|
{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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import React, { useRef, useState } from 'react'
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
import { router, usePage } from '@inertiajs/react'
|
import { router, usePage } from '@inertiajs/react'
|
||||||
import StudioLayout from '../../Layouts/StudioLayout'
|
import StudioLayout from '../../Layouts/StudioLayout'
|
||||||
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
|
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() {
|
export default function StudioGroupCreate() {
|
||||||
const { props } = usePage()
|
const { props } = usePage()
|
||||||
|
const filesCdnUrl = props?.cdn?.files_url || ''
|
||||||
const avatarInputRef = useRef(null)
|
const avatarInputRef = useRef(null)
|
||||||
const bannerInputRef = useRef(null)
|
const bannerInputRef = useRef(null)
|
||||||
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
@@ -25,6 +51,8 @@ export default function StudioGroupCreate() {
|
|||||||
})
|
})
|
||||||
const [avatarPreview, setAvatarPreview] = useState('')
|
const [avatarPreview, setAvatarPreview] = useState('')
|
||||||
const [bannerPreview, setBannerPreview] = 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) => {
|
const updateLink = (index, key, value) => {
|
||||||
setForm((current) => ({
|
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 (
|
return (
|
||||||
<StudioLayout title={props.title} subtitle={props.description}>
|
<StudioLayout title={props.title} subtitle={props.description}>
|
||||||
<div className="mx-auto mb-6 max-w-5xl">
|
<div className="mx-auto mb-6 max-w-5xl">
|
||||||
@@ -94,11 +139,11 @@ export default function StudioGroupCreate() {
|
|||||||
<div className="grid gap-5">
|
<div className="grid gap-5">
|
||||||
<label className="grid gap-2 text-sm text-slate-200">
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
<span>Name</span>
|
<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>
|
||||||
<label className="grid gap-2 text-sm text-slate-200">
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
<span>Slug</span>
|
<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>
|
||||||
<label className="grid gap-2 text-sm text-slate-200">
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
<span>Short description</span>
|
<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">
|
<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>
|
<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]">
|
<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>
|
</div>
|
||||||
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
|
<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">
|
<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">
|
<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>
|
<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]">
|
<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>
|
</div>
|
||||||
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -2,9 +2,24 @@ import React, { useMemo, useRef, useState } from 'react'
|
|||||||
import { router, usePage } from '@inertiajs/react'
|
import { router, usePage } from '@inertiajs/react'
|
||||||
import StudioLayout from '../../Layouts/StudioLayout'
|
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() {
|
export default function StudioGroupSettings() {
|
||||||
const { props } = usePage()
|
const { props } = usePage()
|
||||||
const group = props.studioGroup || {}
|
const group = props.studioGroup || {}
|
||||||
|
const filesCdnUrl = props?.cdn?.files_url || ''
|
||||||
const featuredArtworkOptions = Array.isArray(props.featuredArtworkOptions) ? props.featuredArtworkOptions : []
|
const featuredArtworkOptions = Array.isArray(props.featuredArtworkOptions) ? props.featuredArtworkOptions : []
|
||||||
const avatarInputRef = useRef(null)
|
const avatarInputRef = useRef(null)
|
||||||
const bannerInputRef = useRef(null)
|
const bannerInputRef = useRef(null)
|
||||||
@@ -27,6 +42,8 @@ export default function StudioGroupSettings() {
|
|||||||
})
|
})
|
||||||
const [avatarPreview, setAvatarPreview] = useState('')
|
const [avatarPreview, setAvatarPreview] = useState('')
|
||||||
const [bannerPreview, setBannerPreview] = 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(
|
const selectedFeaturedArtwork = useMemo(
|
||||||
() => featuredArtworkOptions.find((item) => Number(item.id) === Number(form.featured_artwork_id)) || null,
|
() => 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">
|
<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>
|
<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]">
|
<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>
|
</div>
|
||||||
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
|
<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">
|
<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">
|
<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>
|
<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]">
|
<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>
|
</div>
|
||||||
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ const phases = {
|
|||||||
error: 'error',
|
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 = {
|
const initialState = {
|
||||||
phase: phases.idle,
|
phase: phases.idle,
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -163,9 +176,14 @@ function getTypeKey(ct) {
|
|||||||
return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
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 [state, dispatch] = useReducer(reducer, { ...initialState, draftId })
|
||||||
const pollRef = useRef(null)
|
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 extractErrorMessage = useCallback((error, fallback) => {
|
||||||
const message = error?.response?.data?.message
|
const message = error?.response?.data?.message
|
||||||
@@ -379,6 +397,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await window.axios.post('/api/uploads/chunk', payload, {
|
const res = await window.axios.post('/api/uploads/chunk', payload, {
|
||||||
|
timeout: effectiveChunkRequestTimeoutMs,
|
||||||
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
|
headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -389,13 +408,16 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isRequestTooLarge(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
if (attempt < MAX_CHUNK_RETRIES) {
|
if (attempt < MAX_CHUNK_RETRIES) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)))
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1)))
|
||||||
return uploadChunk(sessionId, uploadToken, blob, offset, totalSize, attempt + 1)
|
return uploadChunk(sessionId, uploadToken, blob, offset, totalSize, attempt + 1)
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [])
|
}, [effectiveChunkRequestTimeoutMs])
|
||||||
|
|
||||||
const uploadFile = useCallback(async (sessionId, uploadToken, file) => {
|
const uploadFile = useCallback(async (sessionId, uploadToken, file) => {
|
||||||
dispatch({ type: 'UPLOAD_START' })
|
dispatch({ type: 'UPLOAD_START' })
|
||||||
@@ -415,7 +437,8 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
|||||||
if (offset > totalSize) offset = 0
|
if (offset > totalSize) offset = 0
|
||||||
|
|
||||||
while (offset < totalSize) {
|
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)
|
const chunk = file.slice(offset, nextOffset)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -425,6 +448,14 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
|
|||||||
offset = nextOffset
|
offset = nextOffset
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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.')
|
const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.')
|
||||||
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
|
dispatch({ type: 'UPLOAD_ERROR', error: notice.message })
|
||||||
pushMappedNotice(notice)
|
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 { props } = usePage()
|
||||||
|
|
||||||
const windowFlags = window?.SKINBASE_FLAGS || {}
|
const windowFlags = window?.SKINBASE_FLAGS || {}
|
||||||
@@ -618,6 +649,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
|||||||
<UploadWizard
|
<UploadWizard
|
||||||
initialDraftId={draftId ?? null}
|
initialDraftId={draftId ?? null}
|
||||||
chunkSize={chunkSize}
|
chunkSize={chunkSize}
|
||||||
|
chunkRequestTimeoutMs={chunkRequestTimeoutMs}
|
||||||
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
|
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
|
||||||
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
|
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
|
||||||
groupOptions={Array.isArray(props?.group_options) ? props.group_options : []}
|
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 userId = props?.auth?.user?.id ?? null
|
||||||
const suggestedTags = Array.isArray(props?.suggested_tags) ? props.suggested_tags : []
|
const suggestedTags = Array.isArray(props?.suggested_tags) ? props.suggested_tags : []
|
||||||
const safeChunkSize = Math.max(1, Number(chunkSize || 0))
|
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 fileInputRef = useRef(null)
|
||||||
const [confirmCancel, setConfirmCancel] = useState(false)
|
const [confirmCancel, setConfirmCancel] = useState(false)
|
||||||
const [contentTypes, setContentTypes] = useState([])
|
const [contentTypes, setContentTypes] = useState([])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { router } from '@inertiajs/react'
|
import { router } from '@inertiajs/react'
|
||||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||||
|
import ConfirmDangerModal from './ConfirmDangerModal'
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return 'Unscheduled'
|
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 }) {
|
function ActionLink({ href, icon, label, onClick }) {
|
||||||
if (!href) return null
|
if (!href) return null
|
||||||
|
|
||||||
@@ -79,6 +97,8 @@ function PreviewLink({ item }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GridCard({ item, onExecuteAction, busyKey }) {
|
function GridCard({ item, onExecuteAction, busyKey }) {
|
||||||
|
const readiness = itemReadiness(item)
|
||||||
|
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
trackStudioEvent('studio_item_edited', {
|
trackStudioEvent('studio_item_edited', {
|
||||||
surface: studioSurface(),
|
surface: studioSurface(),
|
||||||
@@ -117,10 +137,10 @@ function GridCard({ item, onExecuteAction, busyKey }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.workflow?.readiness && (
|
{readiness && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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)}`}>
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
|
||||||
{item.workflow.readiness.label}
|
{readiness.label}
|
||||||
</span>
|
</span>
|
||||||
{item.workflow.is_stale_draft && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -137,9 +157,9 @@ function GridCard({ item, onExecuteAction, busyKey }) {
|
|||||||
{item.description || 'No description yet.'}
|
{item.description || 'No description yet.'}
|
||||||
</p>
|
</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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -186,6 +206,8 @@ function GridCard({ item, onExecuteAction, busyKey }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ListRow({ item, onExecuteAction, busyKey }) {
|
function ListRow({ item, onExecuteAction, busyKey }) {
|
||||||
|
const readiness = itemReadiness(item)
|
||||||
|
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
trackStudioEvent('studio_item_edited', {
|
trackStudioEvent('studio_item_edited', {
|
||||||
surface: studioSurface(),
|
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-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>
|
<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">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{item.workflow?.readiness && (
|
{readiness && (
|
||||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
|
||||||
{item.workflow.readiness.label}
|
{readiness.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.workflow?.is_stale_draft && (
|
{item.workflow?.is_stale_draft && (
|
||||||
@@ -241,8 +263,8 @@ function ListRow({ item, onExecuteAction, busyKey }) {
|
|||||||
<span>{metricValue(item, 'comments')} comments</span>
|
<span>{metricValue(item, 'comments')} comments</span>
|
||||||
<span>Updated {formatDate(item.updated_at)}</span>
|
<span>Updated {formatDate(item.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
|
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
|
||||||
<div className="mt-3 text-xs text-slate-500">{item.workflow.readiness.missing.slice(0, 2).join(' • ')}</div>
|
<div className="mt-3 text-xs text-slate-500">{readiness.missing.slice(0, 2).join(' • ')}</div>
|
||||||
)}
|
)}
|
||||||
</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') {
|
if (filter.type === 'select') {
|
||||||
return (
|
return (
|
||||||
<label className="space-y-2 text-sm text-slate-300">
|
<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>
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||||
<select
|
<select
|
||||||
value={filter.value || 'all'}
|
value={controlValue || 'all'}
|
||||||
onChange={(event) => onChange(filter.key, event.target.value)}
|
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"
|
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>
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={filter.value || ''}
|
value={controlValue || ''}
|
||||||
onChange={(event) => onChange(filter.key, event.target.value)}
|
onChange={(event) => onChange(filter.key, event.target.value)}
|
||||||
placeholder={filter.placeholder || filter.label}
|
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"
|
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 [viewMode, setViewMode] = useState('grid')
|
||||||
const [busyKey, setBusyKey] = useState(null)
|
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 filters = listing?.filters || {}
|
||||||
const items = listing?.items || []
|
const items = listing?.items || []
|
||||||
const meta = listing?.meta || {}
|
const meta = listing?.meta || {}
|
||||||
const advancedFilters = listing?.advanced_filters || []
|
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(() => {
|
useEffect(() => {
|
||||||
const stored = window.localStorage.getItem('studio-content-view')
|
const stored = window.localStorage.getItem('studio-content-view')
|
||||||
if (stored === 'grid' || stored === 'list') {
|
if (stored === 'grid' || stored === 'list' || stored === 'table') {
|
||||||
setViewMode(stored)
|
setViewMode(stored)
|
||||||
return
|
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)
|
setViewMode(listing.default_view)
|
||||||
}
|
}
|
||||||
}, [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 updateQuery = (patch) => {
|
||||||
const next = {
|
const next = {
|
||||||
...filters,
|
...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) => {
|
const executeAction = async (action) => {
|
||||||
if (!action?.url || action.type !== 'request') {
|
if (!action?.url || action.type !== 'request') {
|
||||||
return
|
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)) {
|
if (action.confirm && !window.confirm(action.confirm)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -439,13 +757,13 @@ export default function StudioContentBrowser({
|
|||||||
<div className="space-y-6">
|
<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">
|
<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-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">
|
<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>
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={filters.q || ''}
|
value={pendingFilters.q}
|
||||||
onChange={(event) => updateQuery({ q: event.target.value })}
|
onChange={(event) => setPendingFilter('q', event.target.value)}
|
||||||
placeholder="Title, description, module"
|
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"
|
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">
|
<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>
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
|
||||||
<select
|
<select
|
||||||
value={filters.bucket || 'all'}
|
value={pendingFilters.bucket}
|
||||||
onChange={(event) => updateQuery({ bucket: event.target.value })}
|
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"
|
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) => (
|
{(listing?.bucket_options || []).map((option) => (
|
||||||
@@ -488,8 +806,8 @@ export default function StudioContentBrowser({
|
|||||||
<label className="space-y-2 text-sm text-slate-300">
|
<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>
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
||||||
<select
|
<select
|
||||||
value={filters.sort || 'updated_desc'}
|
value={pendingFilters.sort}
|
||||||
onChange={(event) => updateQuery({ sort: event.target.value })}
|
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"
|
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) => (
|
{(listing?.sort_options || []).map((option) => (
|
||||||
@@ -501,8 +819,31 @@ export default function StudioContentBrowser({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{advancedFilters.map((filter) => (
|
{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>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
<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: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
|
||||||
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
|
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
|
||||||
|
{ value: 'table', icon: 'fa-solid fa-table-list', label: 'Table view' },
|
||||||
].map((option) => (
|
].map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -523,7 +865,7 @@ export default function StudioContentBrowser({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quickCreate.map((action) => (
|
{visibleQuickCreate.map((action) => (
|
||||||
<a
|
<a
|
||||||
key={action.key}
|
key={action.key}
|
||||||
href={action.url}
|
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">
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
|
||||||
<p>
|
<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>
|
||||||
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
|
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
|
||||||
</div>
|
</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' ? (
|
viewMode === 'grid' ? (
|
||||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03]">
|
||||||
{items.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -592,6 +1094,15 @@ export default function StudioContentBrowser({
|
|||||||
<i className="fa-solid fa-arrow-right" />
|
<i className="fa-solid fa-arrow-right" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDangerModal
|
||||||
|
open={deleteDialog.open}
|
||||||
|
onClose={closeDeleteDialog}
|
||||||
|
onConfirm={confirmDeleteDialog}
|
||||||
|
title={deleteDialog.title}
|
||||||
|
message={deleteDialog.message}
|
||||||
|
confirmText="DELETE"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,24 @@ function getCsrfToken() {
|
|||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
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) {
|
function sendDiscoveryEvent(endpoint, payload) {
|
||||||
if (!endpoint) return
|
if (!endpoint) return
|
||||||
|
|
||||||
@@ -437,18 +455,27 @@ export default function ArtworkCard({
|
|||||||
|
|
||||||
const item = artwork || {}
|
const item = artwork || {}
|
||||||
const rawAuthor = item.author || item.creator
|
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 title = decodeHtml(item.title || item.name || 'Untitled artwork')
|
||||||
const author = decodeHtml(
|
const author = decodeHtml(
|
||||||
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|
(isGroupPublisher ? publisher?.name : null)
|
||||||
|
|| (typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|
||||||
|| item.author_name
|
|| item.author_name
|
||||||
|| item.uname
|
|| item.uname
|
||||||
|| 'Skinbase Artist'
|
|| 'Skinbase Artist'
|
||||||
)
|
)
|
||||||
const username = rawAuthor?.username || item.author_username || item.username || null
|
const username = isGroupPublisher ? null : (rawAuthor?.username || item.author_username || item.username || null)
|
||||||
const authorLevel = Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
|
const authorLevel = isGroupPublisher ? 0 : Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
|
||||||
const authorRank = rawAuthor?.rank || item.author_rank || item.creator?.rank || ''
|
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 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 likes = item.likes ?? item.favourites ?? 0
|
||||||
const views = item.views ?? item.views_count ?? item.view_count ?? 0
|
const views = item.views ?? item.views_count ?? item.view_count ?? 0
|
||||||
const downloads = item.downloads ?? item.downloads_count ?? item.download_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 aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'
|
||||||
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
|
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
|
||||||
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
|
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 resolvedMetricBadge = metricBadge || item.metric_badge || null
|
||||||
const relativePublishedAt = useMemo(
|
const relativePublishedAt = useMemo(
|
||||||
() => formatRelativeTime(item.published_at || item.publishedAt || null),
|
() => formatRelativeTime(item.published_at || item.publishedAt || null),
|
||||||
@@ -750,7 +777,7 @@ export default function ArtworkCard({
|
|||||||
decoding={decoding}
|
decoding={decoding}
|
||||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.src = IMAGE_FALLBACK
|
swapImageToFallbackOnce(event, IMAGE_FALLBACK)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -761,7 +788,7 @@ export default function ArtworkCard({
|
|||||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
||||||
{authorHref ? (
|
{authorHref ? (
|
||||||
<span>
|
<span>
|
||||||
by {author} <span className="text-slate-500">@{username}</span>
|
by {author} {username ? <span className="text-slate-500">@{username}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>by {author}</span>
|
<span>by {author}</span>
|
||||||
@@ -810,7 +837,7 @@ export default function ArtworkCard({
|
|||||||
fetchPriority={fetchPriority || undefined}
|
fetchPriority={fetchPriority || undefined}
|
||||||
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', imageClassName)}
|
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', imageClassName)}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.src = IMAGE_FALLBACK
|
swapImageToFallbackOnce(event, IMAGE_FALLBACK, { clearResponsive: true })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -880,14 +907,14 @@ export default function ArtworkCard({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.src = AVATAR_FALLBACK
|
swapImageToFallbackOnce(event, AVATAR_FALLBACK)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
|
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
|
||||||
{author}
|
{author}
|
||||||
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
{username ? <span className="text-[11px] text-white/60"> @{username}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
|
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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 }) {
|
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 [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 mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||||
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.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 hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
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 dbWidth = Number(mediaWidth ?? artwork?.width)
|
||||||
const dbHeight = Number(mediaHeight ?? artwork?.height)
|
const dbHeight = Number(mediaHeight ?? artwork?.height)
|
||||||
@@ -30,6 +46,10 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoaded(false)
|
setIsLoaded(false)
|
||||||
|
setMainImageMode('primary')
|
||||||
|
setPreviewImageMode('primary')
|
||||||
|
setShowBackdrop(true)
|
||||||
|
|
||||||
if (hasDbDims) {
|
if (hasDbDims) {
|
||||||
setNaturalDims({ w: dbWidth, h: dbHeight })
|
setNaturalDims({ w: dbWidth, h: dbHeight })
|
||||||
return
|
return
|
||||||
@@ -47,16 +67,15 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
|
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
img.onerror = null
|
||||||
img.src = xlSource
|
img.src = xlSource
|
||||||
}, [xlSource, naturalDims])
|
}, [xlSource, naturalDims])
|
||||||
|
|
||||||
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
|
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
|
||||||
|
|
||||||
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
|
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<img
|
||||||
src={blurBackdropSrc}
|
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"
|
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
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 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" />
|
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
|
||||||
@@ -102,29 +125,52 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
<img
|
{resolvedPreviewSrc ? (
|
||||||
src={md}
|
<img
|
||||||
alt={artwork?.title ?? 'Artwork'}
|
src={resolvedPreviewSrc}
|
||||||
className="absolute inset-0 h-full w-full object-contain rounded-xl"
|
alt={artwork?.title ?? 'Artwork'}
|
||||||
loading="eager"
|
className="absolute inset-0 h-full w-full object-contain rounded-xl"
|
||||||
decoding="async"
|
loading="eager"
|
||||||
fetchPriority="high"
|
decoding="async"
|
||||||
/>
|
fetchPriority="high"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.onerror = null
|
||||||
|
|
||||||
<img
|
if (previewImageMode === 'primary') {
|
||||||
src={lg}
|
setPreviewImageMode('fallback')
|
||||||
srcSet={srcSet}
|
return
|
||||||
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'}`}
|
setPreviewImageMode('hidden')
|
||||||
loading="eager"
|
}}
|
||||||
decoding="async"
|
/>
|
||||||
fetchPriority="high"
|
) : null}
|
||||||
onLoad={() => setIsLoaded(true)}
|
|
||||||
onError={(event) => {
|
{resolvedMainSrc ? (
|
||||||
event.currentTarget.src = FALLBACK_LG
|
<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 && (
|
{onOpenViewer && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -150,14 +150,18 @@ function mapRankApiArtwork(item) {
|
|||||||
const h = item.dimensions?.height ?? null;
|
const h = item.dimensions?.height ?? null;
|
||||||
const thumb = item.thumbnail_url ?? null;
|
const thumb = item.thumbnail_url ?? null;
|
||||||
const webUrl = item.urls?.web ?? item.category?.url ?? null;
|
const webUrl = item.urls?.web ?? item.category?.url ?? null;
|
||||||
|
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
|
||||||
return {
|
return {
|
||||||
id: item.id ?? null,
|
id: item.id ?? null,
|
||||||
name: item.title ?? item.name ?? null,
|
name: item.title ?? item.name ?? null,
|
||||||
thumb: thumb,
|
thumb: thumb,
|
||||||
thumb_url: thumb,
|
thumb_url: thumb,
|
||||||
uname: item.author?.name ?? '',
|
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,
|
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_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 ?? '',
|
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||||
category_name: item.category?.name ?? '',
|
category_name: item.category?.name ?? '',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
const DEFAULT_MAX_TAGS = 15
|
const DEFAULT_MAX_TAGS = 15
|
||||||
const DEFAULT_MIN_LENGTH = 2
|
const DEFAULT_MIN_LENGTH = 2
|
||||||
@@ -27,6 +28,49 @@ function parseTagList(input) {
|
|||||||
.filter(Boolean)
|
.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) {
|
function validateTag(tag, selectedTags, minLength, maxLength, maxTags) {
|
||||||
if (selectedTags.length >= maxTags) return 'Max tags reached'
|
if (selectedTags.length >= maxTags) return 'Max tags reached'
|
||||||
if (tag.length < minLength) return 'Tag too short'
|
if (tag.length < minLength) return 'Tag too short'
|
||||||
@@ -240,13 +284,124 @@ function StatusHints({ error, count, maxTags }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 text-xs">
|
<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">
|
<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>
|
||||||
<span className="text-white/50">{count}/{maxTags}</span>
|
<span className="text-white/50">{count}/{maxTags}</span>
|
||||||
</div>
|
</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({
|
export default function TagInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -268,6 +423,7 @@ export default function TagInput({
|
|||||||
const [searchError, setSearchError] = useState(false)
|
const [searchError, setSearchError] = useState(false)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||||
|
const [pastePreview, setPastePreview] = useState(null)
|
||||||
|
|
||||||
const queryCacheRef = useRef(new Map())
|
const queryCacheRef = useRef(new Map())
|
||||||
const abortControllerRef = useRef(null)
|
const abortControllerRef = useRef(null)
|
||||||
@@ -304,23 +460,38 @@ export default function TagInput({
|
|||||||
}, [selectedTags, updateTags])
|
}, [selectedTags, updateTags])
|
||||||
|
|
||||||
const applyPastedTags = useCallback((rawText) => {
|
const applyPastedTags = useCallback((rawText) => {
|
||||||
const parts = parseTagList(rawText)
|
const preview = analyzePastedTags(rawText, selectedTags, minLength, maxLength, maxTags)
|
||||||
if (parts.length === 0) return
|
|
||||||
|
|
||||||
let next = [...selectedTags]
|
if (preview.parsedCount === 0) return false
|
||||||
for (const part of parts) {
|
|
||||||
const normalized = normalizeTag(part)
|
if (preview.tagsToAdd.length === 0) {
|
||||||
const validation = validateTag(normalized, next, minLength, maxLength, maxTags)
|
if (selectedTags.length >= maxTags || preview.skippedOverflow.length > 0) {
|
||||||
if (!validation) {
|
setError('Max tags reached')
|
||||||
next.push(normalized)
|
} else {
|
||||||
|
setError('No new tags found in pasted text')
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
next = Array.from(new Set(next))
|
setError('')
|
||||||
updateTags(next)
|
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('')
|
setInputValue('')
|
||||||
setError('')
|
setError('')
|
||||||
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
|
setPastePreview(null)
|
||||||
|
}, [pastePreview, updateTags])
|
||||||
|
|
||||||
const runSearch = useCallback(async (query) => {
|
const runSearch = useCallback(async (query) => {
|
||||||
const normalizedQuery = normalizeTag(query)
|
const normalizedQuery = normalizeTag(query)
|
||||||
@@ -452,7 +623,10 @@ export default function TagInput({
|
|||||||
|
|
||||||
const handlePaste = useCallback((event) => {
|
const handlePaste = useCallback((event) => {
|
||||||
const raw = event.clipboardData?.getData('text')
|
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()
|
event.preventDefault()
|
||||||
applyPastedTags(raw)
|
applyPastedTags(raw)
|
||||||
@@ -465,43 +639,53 @@ export default function TagInput({
|
|||||||
}, [inputValue, runSearch])
|
}, [inputValue, runSearch])
|
||||||
|
|
||||||
return (
|
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
|
<SearchInput
|
||||||
inputValue={inputValue}
|
inputValue={inputValue}
|
||||||
onInputChange={(next) => {
|
onInputChange={(next) => {
|
||||||
setInputValue(next)
|
setInputValue(next)
|
||||||
setError('')
|
setError('')
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
expanded={isOpen}
|
expanded={isOpen}
|
||||||
listboxId={listboxId}
|
listboxId={listboxId}
|
||||||
placeholder={placeholder}
|
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />)
|
render(<Harness />)
|
||||||
|
|
||||||
const input = screen.getByLabelText('Tag input')
|
const input = screen.getByLabelText('Tag input')
|
||||||
await userEvent.click(input)
|
await userEvent.click(input)
|
||||||
await userEvent.paste('art, city, night')
|
await userEvent.paste('art, city, night')
|
||||||
|
|
||||||
expect(screen.getByText('art')).not.toBeNull()
|
expect(screen.getByRole('dialog')).not.toBeNull()
|
||||||
expect(screen.getByText('city')).not.toBeNull()
|
expect(screen.queryByRole('button', { name: 'Remove tag art' })).toBeNull()
|
||||||
expect(screen.getByText('night')).not.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 () => {
|
it('handles API failure gracefully', async () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* Value format: string[] of tag slugs
|
* Value format: string[] of tag slugs
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
const MAX_TAGS = 15
|
const MAX_TAGS = 15
|
||||||
const DEBOUNCE_MS = 250
|
const DEBOUNCE_MS = 250
|
||||||
@@ -33,6 +34,55 @@ function normalizeSlug(raw) {
|
|||||||
.slice(0, MAX_LENGTH)
|
.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) {
|
function toListItem(item) {
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
@@ -55,7 +105,7 @@ function toListItem(item) {
|
|||||||
|
|
||||||
// ─── sub-components ───────────────────────────────────────────────────────────
|
// ─── sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
|
function SearchInput({ value, onChange, onKeyDown, onPaste, inputRef, disabled, hint }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -64,6 +114,7 @@ function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={onPaste}
|
||||||
disabled={disabled}
|
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"
|
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…'}
|
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 ───────────────────────────────────────────────────────────
|
// ─── main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TagPicker({
|
export default function TagPicker({
|
||||||
@@ -193,6 +355,7 @@ export default function TagPicker({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [fetchError, setFetchError] = useState(false)
|
const [fetchError, setFetchError] = useState(false)
|
||||||
const [inputError, setInputError] = useState('')
|
const [inputError, setInputError] = useState('')
|
||||||
|
const [pastePreview, setPastePreview] = useState(null)
|
||||||
|
|
||||||
// slug → display name (for chips)
|
// slug → display name (for chips)
|
||||||
const [nameMap, setNameMap] = useState({})
|
const [nameMap, setNameMap] = useState({})
|
||||||
@@ -288,6 +451,20 @@ export default function TagPicker({
|
|||||||
onChange?.(selectedSlugs.filter((s) => s !== slug))
|
onChange?.(selectedSlugs.filter((s) => s !== slug))
|
||||||
}, [selectedSlugs, onChange])
|
}, [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
|
// Commit on Enter / comma / Tab
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e) => {
|
||||||
const commit = e.key === 'Enter' || e.key === ',' || e.key === 'Tab'
|
const commit = e.key === 'Enter' || e.key === ',' || e.key === 'Tab'
|
||||||
@@ -306,6 +483,29 @@ export default function TagPicker({
|
|||||||
addTag(candidate, candidate)
|
addTag(candidate, candidate)
|
||||||
}, [query, selectedSlugs, addTag, removeTag])
|
}, [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
|
// Show "Add 'query'" row when the query doesn't exactly match any result
|
||||||
const querySlug = normalizeSlug(query)
|
const querySlug = normalizeSlug(query)
|
||||||
const showAddNew = Boolean(
|
const showAddNew = Boolean(
|
||||||
@@ -341,6 +541,7 @@ export default function TagPicker({
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(v) => { setQuery(v); setInputError('') }}
|
onChange={(v) => { setQuery(v); setInputError('') }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
hint={placeholder}
|
hint={placeholder}
|
||||||
@@ -402,11 +603,19 @@ export default function TagPicker({
|
|||||||
<span>{selectedSlugs.length}/{maxTags} tags selected</span>
|
<span>{selectedSlugs.length}/{maxTags} tags selected</span>
|
||||||
{atMax
|
{atMax
|
||||||
? <span className="text-amber-300/80">Maximum tags reached</span>
|
? <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>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-xs text-red-300">{error}</p>}
|
{error && <p className="text-xs text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<PastedTagsDialog
|
||||||
|
open={Boolean(pastePreview)}
|
||||||
|
preview={pastePreview}
|
||||||
|
maxTags={maxTags}
|
||||||
|
onClose={closePastePreview}
|
||||||
|
onConfirm={confirmPastePreview}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,4 +73,37 @@ describe('TagPicker', () => {
|
|||||||
expect(screen.getByText('High Contrast')).not.toBeNull()
|
expect(screen.getByText('High Contrast')).not.toBeNull()
|
||||||
expect(screen.getByRole('button', { name: 'Remove tag 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -104,6 +104,7 @@ export default function UploadWizard({
|
|||||||
onValidationStateChange,
|
onValidationStateChange,
|
||||||
initialDraftId = null,
|
initialDraftId = null,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
|
chunkRequestTimeoutMs,
|
||||||
contentTypes = [],
|
contentTypes = [],
|
||||||
suggestedTags = [],
|
suggestedTags = [],
|
||||||
groupOptions = [],
|
groupOptions = [],
|
||||||
@@ -193,6 +194,7 @@ export default function UploadWizard({
|
|||||||
initialDraftId,
|
initialDraftId,
|
||||||
metadata,
|
metadata,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
|
chunkRequestTimeoutMs,
|
||||||
onArtworkCreated: (id) => setResolvedArtworkId(id),
|
onArtworkCreated: (id) => setResolvedArtworkId(id),
|
||||||
onNotice: (notice) => {
|
onNotice: (notice) => {
|
||||||
if (!notice?.message) return
|
if (!notice?.message) return
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNot
|
|||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024
|
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
|
const POLL_INTERVAL_MS = 2000
|
||||||
|
|
||||||
// ─── State machine ───────────────────────────────────────────────────────────
|
// ─── State machine ───────────────────────────────────────────────────────────
|
||||||
@@ -76,6 +78,16 @@ function toPercent(loaded, total) {
|
|||||||
return Math.max(0, Math.min(100, Math.round((loaded / total) * 100)))
|
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) {
|
function getProcessingValue(payload) {
|
||||||
const direct = String(payload?.processing_state || payload?.status || '').toLowerCase()
|
const direct = String(payload?.processing_state || payload?.status || '').toLowerCase()
|
||||||
return direct || 'processing'
|
return direct || 'processing'
|
||||||
@@ -114,6 +126,7 @@ export default function useUploadMachine({
|
|||||||
initialDraftId = null,
|
initialDraftId = null,
|
||||||
metadata,
|
metadata,
|
||||||
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
|
chunkSize = DEFAULT_CHUNK_SIZE_BYTES,
|
||||||
|
chunkRequestTimeoutMs = DEFAULT_CHUNK_REQUEST_TIMEOUT_MS,
|
||||||
onArtworkCreated,
|
onArtworkCreated,
|
||||||
onNotice,
|
onNotice,
|
||||||
}) {
|
}) {
|
||||||
@@ -137,6 +150,11 @@ export default function useUploadMachine({
|
|||||||
const parsed = Number(chunkSize)
|
const parsed = Number(chunkSize)
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_SIZE_BYTES
|
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 ────────────────────────────────────────────────────
|
// ── Controller registry ────────────────────────────────────────────────────
|
||||||
const registerController = useCallback(() => {
|
const registerController = useCallback(() => {
|
||||||
@@ -251,7 +269,8 @@ export default function useUploadMachine({
|
|||||||
const totalSize = file.size
|
const totalSize = file.size
|
||||||
|
|
||||||
while (uploadedForFile < totalSize) {
|
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 blob = file.slice(uploadedForFile, nextOffset)
|
||||||
|
|
||||||
const payload = new FormData()
|
const payload = new FormData()
|
||||||
@@ -266,8 +285,22 @@ export default function useUploadMachine({
|
|||||||
try {
|
try {
|
||||||
await window.axios.post(uploadEndpoints.chunk(), payload, {
|
await window.axios.post(uploadEndpoints.chunk(), payload, {
|
||||||
signal: chunkController.signal,
|
signal: chunkController.signal,
|
||||||
|
timeout: effectiveChunkRequestTimeoutMs,
|
||||||
headers: { 'X-Upload-Token': uploadToken },
|
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 {
|
} finally {
|
||||||
unregisterController(chunkController)
|
unregisterController(chunkController)
|
||||||
}
|
}
|
||||||
@@ -279,7 +312,7 @@ export default function useUploadMachine({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return uploadedBaseBytes + totalSize
|
return uploadedBaseBytes + totalSize
|
||||||
}, [effectiveChunkSize, registerController, unregisterController])
|
}, [effectiveChunkRequestTimeoutMs, effectiveChunkSize, onNotice, registerController, unregisterController])
|
||||||
|
|
||||||
const cancelUploadSession = useCallback(async (sessionId, uploadToken) => {
|
const cancelUploadSession = useCallback(async (sessionId, uploadToken) => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|||||||
@@ -28,15 +28,21 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
|||||||
const payload = error?.response?.data || {}
|
const payload = error?.response?.data || {}
|
||||||
const reason = String(payload?.reason || '').toLowerCase()
|
const reason = String(payload?.reason || '').toLowerCase()
|
||||||
const mapped = REASON_MAP[reason]
|
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
|
const type = mapped?.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 =
|
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 ||
|
mapped?.message ||
|
||||||
(typeof payload?.message === 'string' && payload.message.trim()) ||
|
(typeof payload?.message === 'string' && payload.message.trim()) ||
|
||||||
(typeof error?.message === 'string' && error.message.trim()) ||
|
rawMessage ||
|
||||||
fallback
|
fallback
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,8 +23,12 @@
|
|||||||
|
|
||||||
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
|
$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) (
|
$author = trim((string) (
|
||||||
$art->uname
|
($isGroupPublisher ? data_get($art, 'publisher.name') : null)
|
||||||
|
?? $art->uname
|
||||||
?? $art->author_name
|
?? $art->author_name
|
||||||
?? $art->author
|
?? $art->author
|
||||||
?? ($art->user->name ?? null)
|
?? ($art->user->name ?? null)
|
||||||
@@ -32,11 +36,13 @@
|
|||||||
?? 'Skinbase'
|
?? 'Skinbase'
|
||||||
));
|
));
|
||||||
|
|
||||||
$username = trim((string) (
|
$username = $isGroupPublisher
|
||||||
$art->username
|
? ''
|
||||||
?? ($art->user->username ?? null)
|
: trim((string) (
|
||||||
?? ''
|
$art->username
|
||||||
));
|
?? ($art->user->username ?? null)
|
||||||
|
?? ''
|
||||||
|
));
|
||||||
|
|
||||||
$rawContentType = trim((string) (
|
$rawContentType = trim((string) (
|
||||||
$art->content_type_name
|
$art->content_type_name
|
||||||
@@ -61,7 +67,14 @@
|
|||||||
|
|
||||||
$avatarUserId = $art->user->id ?? $art->user_id ?? null;
|
$avatarUserId = $art->user->id ?? $art->user_id ?? null;
|
||||||
$avatarHash = $art->user->profile->avatar_hash ?? $art->avatar_hash ?? 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'));
|
$license = trim((string) ($art->license ?? 'Standard'));
|
||||||
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
|
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
|
||||||
@@ -131,7 +144,14 @@
|
|||||||
$cardUrl = '#';
|
$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 = [];
|
$metaParts = [];
|
||||||
if ($contentType !== '') {
|
if ($contentType !== '') {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="brand">
|
<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>
|
||||||
<div style="font-weight:700;">Skinbase</div>
|
<div style="font-weight:700;">Skinbase</div>
|
||||||
<div style="font-size:12px;color:#64748b;">New staff application / contact form submission</div>
|
<div style="font-size:12px;color:#64748b;">New staff application / contact form submission</div>
|
||||||
|
|||||||
@@ -325,8 +325,11 @@
|
|||||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
'uname' => $art->uname ?? '',
|
'uname' => $art->uname ?? '',
|
||||||
'username' => $art->username ?? $art->uname ?? '',
|
'username' => $art->username ?? '',
|
||||||
'avatar_url' => $art->avatar_url ?? null,
|
'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_name' => $art->category_name ?? '',
|
||||||
'category_slug' => $art->category_slug ?? '',
|
'category_slug' => $art->category_slug ?? '',
|
||||||
'slug' => $art->slug ?? '',
|
'slug' => $art->slug ?? '',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<footer class="border-t border-neutral-800 bg-nova">
|
<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="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">
|
<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>
|
<span class="sr-only">Skinbase</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,11 @@
|
|||||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
'uname' => $art->uname ?? '',
|
'uname' => $art->uname ?? '',
|
||||||
'username' => $art->username ?? $art->uname ?? '',
|
'username' => $art->username ?? '',
|
||||||
'avatar_url' => $art->avatar_url ?? null,
|
'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_name' => $art->category_name ?? '',
|
||||||
'category_slug' => $art->category_slug ?? '',
|
'category_slug' => $art->category_slug ?? '',
|
||||||
'slug' => $art->slug ?? '',
|
'slug' => $art->slug ?? '',
|
||||||
|
|||||||
@@ -77,6 +77,9 @@
|
|||||||
'uname' => $art->uname ?? '',
|
'uname' => $art->uname ?? '',
|
||||||
'username' => $art->username ?? '',
|
'username' => $art->username ?? '',
|
||||||
'avatar_url' => $art->avatar_url ?? null,
|
'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,
|
'published_at' => $art->published_at ?? null,
|
||||||
'content_type_name' => $art->content_type_name ?? '',
|
'content_type_name' => $art->content_type_name ?? '',
|
||||||
'category_name' => $art->category_name ?? '',
|
'category_name' => $art->category_name ?? '',
|
||||||
|
|||||||
@@ -157,8 +157,11 @@
|
|||||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
'uname' => $art->uname ?? '',
|
'uname' => $art->uname ?? '',
|
||||||
'username' => $art->uname ?? '',
|
'username' => $art->username ?? '',
|
||||||
'avatar_url' => $art->avatar_url ?? null,
|
'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_name' => $art->category_name ?? '',
|
||||||
'category_slug' => $art->category_slug ?? '',
|
'category_slug' => $art->category_slug ?? '',
|
||||||
'slug' => $art->slug ?? '',
|
'slug' => $art->slug ?? '',
|
||||||
|
|||||||
@@ -52,8 +52,11 @@
|
|||||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
'uname' => $art->uname ?? '',
|
'uname' => $art->uname ?? '',
|
||||||
'username' => $art->username ?? $art->uname ?? '',
|
'username' => $art->username ?? '',
|
||||||
'avatar_url' => $art->avatar_url ?? null,
|
'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_name' => $art->category_name ?? '',
|
||||||
'category_slug' => $art->category_slug ?? '',
|
'category_slug' => $art->category_slug ?? '',
|
||||||
'slug' => $art->slug ?? '',
|
'slug' => $art->slug ?? '',
|
||||||
|
|||||||
@@ -21,8 +21,11 @@
|
|||||||
'thumb_url' => $art->thumb_url ?? null,
|
'thumb_url' => $art->thumb_url ?? null,
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
'uname' => $art->uname ?? '',
|
'uname' => $art->uname ?? '',
|
||||||
'username' => $art->username ?? $art->uname ?? '',
|
'username' => $art->username ?? '',
|
||||||
'avatar_url' => $art->avatar_url ?? null,
|
'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_name' => $art->category_name ?? '',
|
||||||
'category_slug' => $art->category_slug ?? '',
|
'category_slug' => $art->category_slug ?? '',
|
||||||
'width' => $art->width ?? null,
|
'width' => $art->width ?? null,
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->nam
|
|||||||
->name('processing-status');
|
->name('processing-status');
|
||||||
|
|
||||||
Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk'])
|
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');
|
->name('chunk');
|
||||||
|
|
||||||
Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish'])
|
Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish'])
|
||||||
|
|||||||
@@ -95,6 +95,32 @@ Schedule::command('posts:publish-scheduled')
|
|||||||
->name('publish-scheduled-posts')
|
->name('publish-scheduled-posts')
|
||||||
->withoutOverlapping();
|
->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 ─────────────────────────────────────────
|
// ── Feed 2.0: Trending Cache Warm-up ─────────────────────────────────────────
|
||||||
// Warm the post trending cache every 2 minutes (complements the 2-min TTL).
|
// Warm the post trending cache every 2 minutes (complements the 2-min TTL).
|
||||||
Schedule::command('posts:warm-trending')
|
Schedule::command('posts:warm-trending')
|
||||||
@@ -111,6 +137,20 @@ Schedule::command('nova:recalculate-rankings --sync-rank-scores')
|
|||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->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')
|
Schedule::command('forum:ai-scan')
|
||||||
->everyTenMinutes()
|
->everyTenMinutes()
|
||||||
->name('forum-ai-scan')
|
->name('forum-ai-scan')
|
||||||
|
|||||||
@@ -774,6 +774,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
|
|||||||
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
|
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
|
||||||
'filesCdnUrl' => config('cdn.files_url'),
|
'filesCdnUrl' => config('cdn.files_url'),
|
||||||
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
|
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
|
||||||
|
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
|
||||||
'feature_flags' => [
|
'feature_flags' => [
|
||||||
'uploads_v2' => (bool) config('features.uploads_v2', false),
|
'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,
|
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
|
||||||
'filesCdnUrl' => config('cdn.files_url'),
|
'filesCdnUrl' => config('cdn.files_url'),
|
||||||
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
|
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
|
||||||
|
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
|
||||||
'feature_flags' => [
|
'feature_flags' => [
|
||||||
'uploads_v2' => (bool) config('features.uploads_v2', false),
|
'uploads_v2' => (bool) config('features.uploads_v2', false),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ run_local_build=1
|
|||||||
run_remote_migrations=1
|
run_remote_migrations=1
|
||||||
run_db_sync=0
|
run_db_sync=0
|
||||||
run_meilisearch_setup=0
|
run_meilisearch_setup=0
|
||||||
|
auto_detect_meilisearch=1
|
||||||
db_sync_source=""
|
db_sync_source=""
|
||||||
legacy_db_sync_mode=0
|
legacy_db_sync_mode=0
|
||||||
force_db_sync=0
|
force_db_sync=0
|
||||||
skip_maintenance=0
|
skip_maintenance=0
|
||||||
db_sync_confirm_target="${DB_SYNC_CONFIRM_TARGET:-}"
|
db_sync_confirm_target="${DB_SYNC_CONFIRM_TARGET:-}"
|
||||||
db_sync_confirm_phrase="${DB_SYNC_CONFIRM_PHRASE:-}"
|
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() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -38,7 +41,8 @@ Options:
|
|||||||
Must equal 'replace production db from local' when running non-interactively.
|
Must equal 'replace production db from local' when running non-interactively.
|
||||||
--with-db Legacy alias for --with-db-from=local.
|
--with-db Legacy alias for --with-db-from=local.
|
||||||
--force-db-sync Legacy extra confirmation flag for --with-db.
|
--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.
|
--no-maintenance Skip php artisan down/up during deploy.
|
||||||
--help Show this help.
|
--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
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--skip-build)
|
--skip-build)
|
||||||
@@ -161,6 +256,13 @@ while [[ $# -gt 0 ]]; do
|
|||||||
;;
|
;;
|
||||||
--with-meilisearch)
|
--with-meilisearch)
|
||||||
run_meilisearch_setup=1
|
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)
|
--no-maintenance)
|
||||||
skip_maintenance=1
|
skip_maintenance=1
|
||||||
@@ -202,27 +304,17 @@ if [[ "$run_local_build" -eq 1 ]]; then
|
|||||||
run_frontend_build
|
run_frontend_build
|
||||||
fi
|
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..."
|
echo "Syncing application files to $remote_server..."
|
||||||
"$rsync_bin" -avz \
|
"$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$remote_folder/"
|
||||||
--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/"
|
|
||||||
|
|
||||||
if [[ "$run_db_sync" -eq 1 ]]; then
|
if [[ "$run_db_sync" -eq 1 ]]; then
|
||||||
echo "Replacing the production database from the local dump..."
|
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" \
|
RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \
|
||||||
SKIP_MAINTENANCE="$skip_maintenance" \
|
SKIP_MAINTENANCE="$skip_maintenance" \
|
||||||
RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \
|
RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \
|
||||||
|
MEILISEARCH_MODELS_CSV="$(printf '%q' "$meilisearch_models_csv")" \
|
||||||
'bash -s' <<'EOF'
|
'bash -s' <<'EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$REMOTE_FOLDER"
|
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() {
|
bring_app_up() {
|
||||||
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
||||||
"$PHP_BIN" artisan up >/dev/null 2>&1 || true
|
"$PHP_BIN" artisan up >/dev/null 2>&1 || true
|
||||||
@@ -270,12 +385,18 @@ fi
|
|||||||
"$PHP_BIN" artisan queue:restart || true
|
"$PHP_BIN" artisan queue:restart || true
|
||||||
|
|
||||||
if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then
|
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)..."
|
echo "Importing searchable models into Meilisearch (auto-creates indexes)..."
|
||||||
"$PHP_BIN" artisan scout:import "App\\Models\\Artwork"
|
for model in "${meilisearch_models[@]}"; do
|
||||||
"$PHP_BIN" artisan scout:import "App\\Models\\User"
|
[[ -n "$model" ]] || continue
|
||||||
"$PHP_BIN" artisan scout:import "App\\Models\\Group"
|
echo " -> $model"
|
||||||
"$PHP_BIN" artisan scout:import "App\\Models\\Post"
|
"$PHP_BIN" artisan scout:import "$model"
|
||||||
"$PHP_BIN" artisan scout:import "App\\Models\\Message"
|
done
|
||||||
echo "Syncing Meilisearch index settings..."
|
echo "Syncing Meilisearch index settings..."
|
||||||
"$PHP_BIN" artisan scout:sync-index-settings
|
"$PHP_BIN" artisan scout:sync-index-settings
|
||||||
echo "Meilisearch setup complete."
|
echo "Meilisearch setup complete."
|
||||||
|
|||||||
65
tests/Feature/DiscoverRisingFeedTest.php
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
@@ -40,3 +41,25 @@ it('home page still renders with rising section data', function () {
|
|||||||
$this->get('/')
|
$this->get('/')
|
||||||
->assertStatus(200);
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkStats;
|
use App\Models\ArtworkStats;
|
||||||
use App\Models\ArtworkMetricSnapshotHourly;
|
use App\Models\ArtworkMetricSnapshotHourly;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: create an artwork row without triggering observers (avoids GREATEST() SQLite issue).
|
* 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
|
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 () {
|
it('handles negative deltas gracefully by clamping to zero', function () {
|
||||||
$artwork = createArtworkWithoutObserver([
|
$artwork = createArtworkWithoutObserver([
|
||||||
'is_approved' => true,
|
'is_approved' => true,
|
||||||
|
|||||||