Compare commits
29 Commits
87d60af5a9
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| ff96ef796e | |||
| 8d108b8a76 | |||
| 6b83d76cd1 | |||
| 0c5dde9b22 | |||
| 82f2b1f660 | |||
| 7a8bc8e22a | |||
| 8fa3adf4df | |||
| bd8a5c14a0 | |||
| 2c2c0f6722 | |||
| ee24111d59 | |||
| a3cfc6c17f | |||
| 90e93f0d42 | |||
| 44354e5bea | |||
| a9dfa6ea11 | |||
| b6be6ed2ac | |||
| caf1464aa5 | |||
| 79235133f0 | |||
| 396712bb3d | |||
| 18cea8b0f0 | |||
| 257b0dbef6 | |||
| 28e7e46e13 | |||
| 874f8feb9c | |||
| 961d21e91e | |||
| 35011001ba | |||
| 67be537c86 | |||
| 19d5a9ed3e | |||
| c8c7a4d100 | |||
| 157c6d49e8 | |||
| a0b903f09d |
@@ -41,6 +41,14 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
# Skinbase Nova conditional public sessions
|
||||
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
|
||||
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
|
||||
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
|
||||
|
||||
# Debug only; do not enable permanently in production
|
||||
SKINBASE_SESSION_DEBUG_HEADER=false
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -85,14 +86,8 @@ class PublishScheduledArtworksCommand extends Command
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->save();
|
||||
|
||||
// Trigger Meilisearch reindex via Scout (if searchable trait present)
|
||||
if (method_exists($artwork, 'searchable')) {
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
// Trigger Meilisearch reindex directly — no Scout hop.
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
|
||||
@@ -5,26 +5,205 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
class RebuildArtworkSearchIndex extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
|
||||
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
|
||||
protected $signature = 'artworks:search-rebuild
|
||||
{--chunk=500 : Number of artworks per chunk}
|
||||
{--limit= : Stop after processing this many artworks (useful for testing)}
|
||||
{--reverse : Process artworks newest-first (highest ID first)}
|
||||
{--sync : Write directly to Meilisearch (no queue) and show per-artwork results}';
|
||||
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based). Use --sync for verbose direct writes.';
|
||||
|
||||
public function __construct(private readonly ArtworkSearchIndexer $indexer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
public function handle(MeilisearchClient $client): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$reverse = (bool) $this->option('reverse');
|
||||
$sync = (bool) $this->option('sync');
|
||||
|
||||
$this->info("Dispatching index jobs in chunks of {$chunk}…");
|
||||
$this->indexer->rebuildAll($chunk);
|
||||
$this->info('All jobs dispatched. Workers will process them asynchronously.');
|
||||
if ($sync) {
|
||||
return $this->handleSync($client, $chunk, $limit, $reverse);
|
||||
}
|
||||
|
||||
return $this->handleQueue($chunk, $limit, $reverse);
|
||||
}
|
||||
|
||||
// ── Queue mode (default) ──────────────────────────────────────────────────
|
||||
|
||||
private function handleQueue(int $chunk, ?int $limit, bool $reverse): int
|
||||
{
|
||||
$uncapped = Artwork::query()->public()->published()->count();
|
||||
$total = $limit !== null ? min($limit, $uncapped) : $uncapped;
|
||||
|
||||
if ($total === 0) {
|
||||
$this->warn('No public, published artworks matched the rebuild query. Nothing was queued.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$estimatedChunks = (int) ceil($total / $chunk);
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queueing Meilisearch rebuild for %d artwork(s) in %d chunk(s) of up to %d%s%s.',
|
||||
$total,
|
||||
$estimatedChunks,
|
||||
$chunk,
|
||||
$reverse ? ', newest first' : '',
|
||||
$limit !== null ? " (limit {$limit})" : '',
|
||||
));
|
||||
$this->line('This command only dispatches queue jobs. Workers process the actual indexing asynchronously.');
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
|
||||
$bar->start();
|
||||
|
||||
$startedAt = microtime(true);
|
||||
|
||||
$stats = $this->indexer->rebuildAll(
|
||||
$chunk,
|
||||
function (int $chunkNumber, int $chunkCount, int $dispatched, int $totalItems, int $firstId, int $lastId) use ($bar): void {
|
||||
|
||||
$bar->advance($chunkCount);
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$bar->clear();
|
||||
$this->line(sprintf(
|
||||
'Chunk %d queued %d artwork(s) [ids %d-%d] (%d/%d dispatched).',
|
||||
$chunkNumber,
|
||||
$chunkCount,
|
||||
$firstId,
|
||||
$lastId,
|
||||
$dispatched,
|
||||
$totalItems,
|
||||
));
|
||||
$bar->display();
|
||||
}
|
||||
},
|
||||
$reverse,
|
||||
$limit,
|
||||
);
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$elapsed = microtime(true) - $startedAt;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d artwork(s) across %d chunk(s) in %.2f seconds.',
|
||||
$stats['dispatched'],
|
||||
$stats['chunks'],
|
||||
$elapsed,
|
||||
));
|
||||
$this->line('Workers will process the actual Meilisearch writes asynchronously.');
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line('Tip: use -v for per-chunk output, or monitor Horizon/queue workers for completion.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Sync mode (--sync) ────────────────────────────────────────────────────
|
||||
|
||||
private function handleSync(MeilisearchClient $client, int $chunk, ?int $limit, bool $reverse): int
|
||||
{
|
||||
$this->info(sprintf(
|
||||
'<options=bold>[SYNC MODE]</> Writing directly to Meilisearch%s%s — no queue involved.',
|
||||
$reverse ? ', newest first' : '',
|
||||
$limit !== null ? ", limit {$limit}" : '',
|
||||
));
|
||||
$this->newLine();
|
||||
|
||||
$query = Artwork::with([
|
||||
'user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat',
|
||||
])
|
||||
->withoutGlobalScopes() // include non-public so we can report "why not"
|
||||
->whereNotNull('id'); // all artworks
|
||||
|
||||
if ($reverse) {
|
||||
$query->orderByDesc('id');
|
||||
} else {
|
||||
$query->orderBy('id');
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$total = (clone $query)->count();
|
||||
$indexed = 0;
|
||||
$removed = 0;
|
||||
$failed = 0;
|
||||
$processed = 0;
|
||||
$startedAt = microtime(true);
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
|
||||
$bar->start();
|
||||
|
||||
$query->chunk($chunk, function ($artworks) use ($client, $bar, &$indexed, &$removed, &$failed, &$processed): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
$processed++;
|
||||
$id = (int) $artwork->id;
|
||||
$title = (string) ($artwork->title ?? '(no title)');
|
||||
|
||||
// Determine eligibility and reason
|
||||
$reasons = [];
|
||||
if (! $artwork->is_public) { $reasons[] = 'not public'; }
|
||||
if (! $artwork->is_approved) { $reasons[] = 'not approved'; }
|
||||
if ($artwork->published_at === null) { $reasons[] = 'not published'; }
|
||||
if ($artwork->deleted_at !== null) { $reasons[] = 'soft-deleted'; }
|
||||
|
||||
$eligible = empty($reasons);
|
||||
|
||||
try {
|
||||
$indexName = $artwork->searchableAs();
|
||||
|
||||
if ($eligible) {
|
||||
$document = $artwork->toSearchableArray();
|
||||
$client->index($indexName)->addDocuments([$document]);
|
||||
$indexed++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <info>✓ indexed</info> #%d "%s"', $id, $title));
|
||||
} else {
|
||||
$client->index($indexName)->deleteDocument($id);
|
||||
$removed++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <comment>– removed</comment> #%d "%s" [%s]', $id, $title, implode(', ', $reasons)));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <error>✗ failed</error> #%d "%s" %s', $id, $title, $e->getMessage()));
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$elapsed = microtime(true) - $startedAt;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Done in %.2f s — %d indexed, %d removed from index, %d failed (of %d processed).',
|
||||
$elapsed,
|
||||
$indexed,
|
||||
$removed,
|
||||
$failed,
|
||||
$processed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||
use App\Http\Requests\Uploads\UploadStatusRequest;
|
||||
use App\Jobs\GenerateDerivativesJob;
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Uploads\UploadChunkService;
|
||||
@@ -19,6 +22,7 @@ use App\Services\Uploads\UploadCancelService;
|
||||
use App\Services\Uploads\UploadAuditService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Services\Uploads\UploadQuotaService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadStatusService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -43,6 +47,7 @@ use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadController extends Controller
|
||||
@@ -81,11 +86,13 @@ final class UploadController extends Controller
|
||||
UploadFinishRequest $request,
|
||||
UploadPipelineService $pipeline,
|
||||
UploadSessionRepository $sessions,
|
||||
UploadAuditService $audit
|
||||
UploadAuditService $audit,
|
||||
UploadQueueService $queue
|
||||
) {
|
||||
$user = $request->user();
|
||||
$sessionId = (string) $request->validated('session_id');
|
||||
$artworkId = (int) $request->validated('artwork_id');
|
||||
$batchItemId = (int) $request->validated('batch_item_id', 0);
|
||||
$originalFileName = $request->validated('file_name');
|
||||
$archiveSessionId = $request->validated('archive_session_id');
|
||||
$archiveOriginalFileName = $request->validated('archive_file_name');
|
||||
@@ -96,16 +103,33 @@ final class UploadController extends Controller
|
||||
$session = $sessions->getOrFail($sessionId);
|
||||
|
||||
$request->artwork();
|
||||
$request->batchItem();
|
||||
|
||||
$failResponse = function (int $statusCode, string $message, ?string $reason = null) use ($queue, $user, $batchItemId) {
|
||||
if ($batchItemId > 0) {
|
||||
$queue->markItemFailedForUser($user, $batchItemId, $reason ?? 'upload_failed', $message);
|
||||
}
|
||||
|
||||
return response()->json(array_filter([
|
||||
'message' => $message,
|
||||
'reason' => $reason,
|
||||
], static fn (mixed $value): bool => $value !== null), $statusCode);
|
||||
};
|
||||
|
||||
$validated = $pipeline->validateAndHash($sessionId);
|
||||
if (! $validated->validation->ok || ! $validated->hash) {
|
||||
return response()->json([
|
||||
'message' => 'Upload validation failed.',
|
||||
'reason' => $validated->validation->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
return $failResponse(
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
'Upload validation failed.',
|
||||
$validated->validation->reason
|
||||
);
|
||||
}
|
||||
|
||||
if ($pipeline->originalHashExists($validated->hash)) {
|
||||
if ($batchItemId > 0) {
|
||||
$queue->markItemFailedForUser($user, $batchItemId, 'duplicate_hash', 'Duplicate upload is not allowed. This file already exists.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Duplicate upload is not allowed. This file already exists.',
|
||||
'reason' => 'duplicate_hash',
|
||||
@@ -115,28 +139,31 @@ final class UploadController extends Controller
|
||||
|
||||
$scan = $pipeline->scan($sessionId);
|
||||
if (! $scan->ok) {
|
||||
return response()->json([
|
||||
'message' => 'Upload scan failed.',
|
||||
'reason' => $scan->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
return $failResponse(
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
'Upload scan failed.',
|
||||
$scan->reason
|
||||
);
|
||||
}
|
||||
|
||||
$validatedArchive = null;
|
||||
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
|
||||
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
|
||||
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
|
||||
return response()->json([
|
||||
'message' => 'Archive validation failed.',
|
||||
'reason' => $validatedArchive->validation->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
return $failResponse(
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
'Archive validation failed.',
|
||||
$validatedArchive->validation->reason
|
||||
);
|
||||
}
|
||||
|
||||
$archiveScan = $pipeline->scan($archiveSessionId);
|
||||
if (! $archiveScan->ok) {
|
||||
return response()->json([
|
||||
'message' => 'Archive scan failed.',
|
||||
'reason' => $archiveScan->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
return $failResponse(
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
'Archive scan failed.',
|
||||
$archiveScan->reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,18 +176,20 @@ final class UploadController extends Controller
|
||||
|
||||
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
|
||||
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
|
||||
return response()->json([
|
||||
'message' => 'Screenshot validation failed.',
|
||||
'reason' => $validatedScreenshot->validation->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
return $failResponse(
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
'Screenshot validation failed.',
|
||||
$validatedScreenshot->validation->reason
|
||||
);
|
||||
}
|
||||
|
||||
$screenshotScan = $pipeline->scan($screenshotSessionId);
|
||||
if (! $screenshotScan->ok) {
|
||||
return response()->json([
|
||||
'message' => 'Screenshot scan failed.',
|
||||
'reason' => $screenshotScan->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
return $failResponse(
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
'Screenshot scan failed.',
|
||||
$screenshotScan->reason
|
||||
);
|
||||
}
|
||||
|
||||
$validatedAdditionalScreenshots[] = [
|
||||
@@ -171,7 +200,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots, $queue, $batchItemId) {
|
||||
if ((bool) config('uploads.queue_derivatives', false)) {
|
||||
GenerateDerivativesJob::dispatch(
|
||||
$sessionId,
|
||||
@@ -181,8 +210,14 @@ final class UploadController extends Controller
|
||||
is_string($archiveSessionId) ? $archiveSessionId : null,
|
||||
$validatedArchive?->hash,
|
||||
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
|
||||
$validatedAdditionalScreenshots
|
||||
$validatedAdditionalScreenshots,
|
||||
$batchItemId > 0 ? $batchItemId : null
|
||||
)->afterCommit();
|
||||
|
||||
if ($batchItemId > 0) {
|
||||
$queue->markItemProcessingQueued($batchItemId);
|
||||
}
|
||||
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
@@ -197,9 +232,15 @@ final class UploadController extends Controller
|
||||
$validatedAdditionalScreenshots
|
||||
);
|
||||
|
||||
if ($batchItemId > 0) {
|
||||
$queue->markItemMediaProcessed($batchItemId);
|
||||
}
|
||||
|
||||
// Derivatives are available now; dispatch AI auto-tagging.
|
||||
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
|
||||
return UploadSessionStatus::PROCESSED;
|
||||
});
|
||||
|
||||
@@ -223,6 +264,10 @@ final class UploadController extends Controller
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($batchItemId > 0) {
|
||||
$queue->markItemFailedForUser($user, $batchItemId, 'upload_finish_failed', $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Upload finish failed.',
|
||||
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
@@ -559,7 +604,7 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -567,7 +612,7 @@ final class UploadController extends Controller
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:15'],
|
||||
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
@@ -584,6 +629,9 @@ final class UploadController extends Controller
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
'world_submissions' => ['nullable', 'array', 'max:12'],
|
||||
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
|
||||
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
@@ -660,6 +708,7 @@ final class UploadController extends Controller
|
||||
$artwork->save();
|
||||
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
|
||||
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
|
||||
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
@@ -671,14 +720,7 @@ final class UploadController extends Controller
|
||||
$artwork->published_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
$artwork->unsearchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to remove scheduled artwork from search index', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -699,18 +741,7 @@ final class UploadController extends Controller
|
||||
$artwork->publish_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sync artwork search index after publish', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
@@ -754,7 +785,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews, WorldSubmissionService $submissions)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -762,7 +793,7 @@ final class UploadController extends Controller
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:15'],
|
||||
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
@@ -776,6 +807,9 @@ final class UploadController extends Controller
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
'world_submissions' => ['nullable', 'array', 'max:12'],
|
||||
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
|
||||
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
if (! ctype_digit($id)) {
|
||||
@@ -797,6 +831,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
$artwork = $reviews->submit($group, $artwork, $user, $validated);
|
||||
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@@ -172,7 +172,9 @@ class NewsController extends Controller
|
||||
$userId = Auth::id();
|
||||
$session = 'news_view_' . $article->id;
|
||||
|
||||
if ($request->session()->has($session)) {
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
|
||||
if ($canReadSession && $request->session()->has($session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,8 +187,10 @@ class NewsController extends Controller
|
||||
|
||||
$article->incrementViews();
|
||||
|
||||
if ($canReadSession) {
|
||||
$request->session()->put($session, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function sidebarData(): array
|
||||
{
|
||||
|
||||
@@ -265,16 +265,8 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Meilisearch may be unavailable
|
||||
}
|
||||
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
|
||||
$this->searchIndexer->update($artwork);
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Group;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\GroupMembershipService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Studio\CreatorStudioAnalyticsService;
|
||||
@@ -24,10 +25,12 @@ use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Studio\CreatorStudioScheduledService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -74,7 +77,7 @@ final class StudioController extends Controller
|
||||
public function content(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
|
||||
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'content_type', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioContentIndex', [
|
||||
@@ -92,7 +95,7 @@ final class StudioController extends Controller
|
||||
{
|
||||
$provider = $this->content->provider('artworks');
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks');
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioArtworks', [
|
||||
@@ -121,6 +124,23 @@ final class StudioController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadQueue(Request $request): Response
|
||||
{
|
||||
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
|
||||
$request->user(),
|
||||
$request->only(['batch_id', 'status', 'sort'])
|
||||
);
|
||||
|
||||
return Inertia::render('Studio/StudioUploadQueue', [
|
||||
'title' => 'Upload Queue',
|
||||
'description' => 'Upload multiple artworks, track processing, and publish only when each draft is ready.',
|
||||
'queue' => $queue,
|
||||
'contentTypes' => $this->getCategories(),
|
||||
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
|
||||
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived (/studio/archived)
|
||||
*/
|
||||
@@ -426,6 +446,9 @@ final class StudioController extends Controller
|
||||
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
|
||||
->findOrFail($id);
|
||||
|
||||
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
|
||||
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
|
||||
$membershipService = app(GroupMembershipService::class);
|
||||
@@ -455,11 +478,15 @@ final class StudioController extends Controller
|
||||
'artwork_timezone' => $artwork->artwork_timezone,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'download_url' => route('art.download', ['id' => $artwork->id]),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_ext' => $artwork->file_ext,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
|
||||
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
|
||||
@@ -484,6 +511,7 @@ final class StudioController extends Controller
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
|
||||
'contentTypes' => $this->getCategories(),
|
||||
'groupOptions' => $availableGroups,
|
||||
'contributorOptionsByGroup' => $contributorOptionsByGroup,
|
||||
@@ -588,4 +616,51 @@ final class StudioController extends Controller
|
||||
default => 'studio.index',
|
||||
};
|
||||
}
|
||||
|
||||
private function screenshotAssetsForArtwork(int $artworkId): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_files')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
return DB::table('artwork_files')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('variant', 'like', 'shot%')
|
||||
->orderBy('variant')
|
||||
->get(['variant', 'path', 'mime', 'size'])
|
||||
->map(function ($row, int $index) use ($base): ?array {
|
||||
$path = trim((string) ($row->path ?? ''), '/');
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $base . '/' . $path;
|
||||
|
||||
return [
|
||||
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
|
||||
'size' => (int) ($row->size ?? 0),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function artworkHasArchiveFile(int $artworkId): bool
|
||||
{
|
||||
if (! Schema::hasTable('artwork_files')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::table('artwork_files')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('variant', 'orig_archive')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class StudioUploadQueueApiController extends Controller
|
||||
{
|
||||
public function index(Request $request, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$queue->listPayload($request->user(), $request->only(['batch_id', 'status', 'sort']))
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['nullable', 'string', 'max:160'],
|
||||
'files' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'files.*.name' => ['required', 'string', 'max:255'],
|
||||
'defaults' => ['nullable', 'array'],
|
||||
'defaults.category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'defaults.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'defaults.tags.*' => ['string', 'max:64'],
|
||||
'defaults.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'defaults.is_mature' => ['nullable', 'boolean'],
|
||||
'defaults.group' => ['nullable', 'string', 'max:90'],
|
||||
]);
|
||||
|
||||
$batch = $queue->createBatch(
|
||||
$request->user(),
|
||||
(array) $validated['files'],
|
||||
(array) ($validated['defaults'] ?? []),
|
||||
Arr::get($validated, 'name')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'batch' => [
|
||||
'id' => (int) $batch->id,
|
||||
'name' => $batch->name,
|
||||
],
|
||||
'items' => $batch->items->map(fn ($item): array => [
|
||||
'id' => (int) $item->id,
|
||||
'artwork_id' => (int) $item->artwork_id,
|
||||
'original_filename' => (string) $item->original_filename,
|
||||
])->values()->all(),
|
||||
'queue' => $queue->listPayload($request->user(), ['batch_id' => (int) $batch->id]),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function markFailed(Request $request, int $id, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'error_code' => ['nullable', 'string', 'max:64'],
|
||||
'error_message' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
$queue->markItemFailedForUser(
|
||||
$request->user(),
|
||||
$id,
|
||||
(string) ($validated['error_code'] ?? 'upload_failed'),
|
||||
(string) ($validated['error_message'] ?? 'Upload failed before processing completed.')
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function bulk(Request $request, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'action' => ['required', 'string', 'in:publish,delete,apply_category,apply_tags,set_visibility,generate_ai'],
|
||||
'item_ids' => ['required', 'array', 'min:1', 'max:200'],
|
||||
'item_ids.*' => ['integer'],
|
||||
'params' => ['nullable', 'array'],
|
||||
'params.category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'params.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'params.tags.*' => ['string', 'max:64'],
|
||||
'params.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'confirm' => ['required_if:action,delete', 'string'],
|
||||
]);
|
||||
|
||||
if (($validated['action'] ?? '') === 'delete' && ($validated['confirm'] ?? '') !== 'DELETE') {
|
||||
return response()->json([
|
||||
'errors' => ['You must type DELETE to confirm draft deletion.'],
|
||||
'success' => 0,
|
||||
'failed' => count((array) ($validated['item_ids'] ?? [])),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $queue->bulkAction(
|
||||
$request->user(),
|
||||
(string) $validated['action'],
|
||||
(array) $validated['item_ids'],
|
||||
(array) ($validated['params'] ?? [])
|
||||
);
|
||||
|
||||
return response()->json($result, $result['success'] > 0 ? 200 : 422);
|
||||
}
|
||||
|
||||
public function retry(Request $request, int $id, UploadQueueService $queue): JsonResponse
|
||||
{
|
||||
$queue->retryProcessingForUser($request->user(), $id);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ final class DiscoverController extends Controller
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->whereRaw('MONTH(published_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(published_at) = ?', [$today->day])
|
||||
@@ -558,6 +559,7 @@ final class DiscoverController extends Controller
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
'categories.contentType:id,slug,name',
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
|
||||
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
||||
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
||||
'page_canonical' => $baseUrl,
|
||||
'page_robots' => 'noindex,follow',
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
|
||||
{
|
||||
public function handle($request, Closure $next): mixed
|
||||
{
|
||||
if (! $request instanceof Request) {
|
||||
return parent::handle($request, $next);
|
||||
}
|
||||
|
||||
if ($request->attributes->get('skinbase.session_skipped') === true || ! $request->hasSession()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return parent::handle($request, $next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ConditionalStartSession extends StartSession
|
||||
{
|
||||
public function handle($request, Closure $next): mixed
|
||||
{
|
||||
if (! $request instanceof Request || ! config('skinbase-sessions.enabled', true)) {
|
||||
return parent::handle($request, $next);
|
||||
}
|
||||
|
||||
if ($this->shouldSkipSession($request)) {
|
||||
$request->attributes->set('skinbase.session_skipped', true);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($response instanceof Response && config('skinbase-sessions.debug_header', false)) {
|
||||
$response->headers->set('X-Skinbase-Session', 'skipped');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$request->attributes->set('skinbase.session_skipped', false);
|
||||
|
||||
$response = parent::handle($request, $next);
|
||||
|
||||
if ($response instanceof Response && config('skinbase-sessions.debug_header', false)) {
|
||||
$response->headers->set('X-Skinbase-Session', 'started');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function shouldSkipSession(Request $request): bool
|
||||
{
|
||||
if (! $this->isSafeReadMethod($request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasExistingSessionCookie($request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->matchesAnyPath($request, config('skinbase-sessions.always_session_paths', []))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->matchesAnyPath($request, config('skinbase-sessions.public_paths', []))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config('skinbase-sessions.skip_anonymous_public_get', true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return config('skinbase-sessions.skip_known_crawlers_on_public_get', true)
|
||||
&& $this->isKnownCrawler($request);
|
||||
}
|
||||
|
||||
protected function isSafeReadMethod(Request $request): bool
|
||||
{
|
||||
return in_array($request->getMethod(), ['GET', 'HEAD'], true);
|
||||
}
|
||||
|
||||
protected function hasExistingSessionCookie(Request $request): bool
|
||||
{
|
||||
$cookieName = config('session.cookie');
|
||||
|
||||
return is_string($cookieName)
|
||||
&& $cookieName !== ''
|
||||
&& $request->cookies->has($cookieName);
|
||||
}
|
||||
|
||||
protected function matchesAnyPath(Request $request, array $patterns): bool
|
||||
{
|
||||
foreach ($patterns as $pattern) {
|
||||
if (! is_string($pattern) || $pattern === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($pattern === '/' && $request->path() === '/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normalizedPattern = trim($pattern, '/');
|
||||
|
||||
if ($normalizedPattern !== '' && $request->is($normalizedPattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function isKnownCrawler(Request $request): bool
|
||||
{
|
||||
$userAgent = strtolower((string) $request->userAgent());
|
||||
|
||||
if ($userAgent === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (config('skinbase-sessions.bot_user_agent_keywords', []) as $keyword) {
|
||||
$normalizedKeyword = strtolower((string) $keyword);
|
||||
|
||||
if ($normalizedKeyword !== '' && str_contains($userAgent, $normalizedKeyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ConditionalValidateCsrfToken extends ValidateCsrfToken
|
||||
{
|
||||
public function handle($request, Closure $next): mixed
|
||||
{
|
||||
if ($request instanceof Request && $request->attributes->get('skinbase.session_skipped') === true) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return parent::handle($request, $next);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,15 @@ final class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
protected $rootView = 'upload';
|
||||
|
||||
protected function canReadSessionAuth(Request $request): bool
|
||||
{
|
||||
if ($request->attributes->get('skinbase.session_skipped') === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $request->hasSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the root Blade view based on route prefix.
|
||||
*/
|
||||
@@ -58,13 +67,16 @@ final class HandleInertiaRequests extends Middleware
|
||||
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$canReadSessionAuth = $this->canReadSessionAuth($request);
|
||||
$user = $canReadSessionAuth ? $request->user() : null;
|
||||
|
||||
return array_merge(parent::share($request), [
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'is_admin' => $request->user()->isAdmin(),
|
||||
'is_moderator' => $request->user()->isModerator(),
|
||||
'user' => $user ? [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'is_admin' => $user->isAdmin(),
|
||||
'is_moderator' => $user->isModerator(),
|
||||
] : null,
|
||||
],
|
||||
'cdn' => [
|
||||
@@ -84,8 +96,8 @@ final class HandleInertiaRequests extends Middleware
|
||||
'group_assets' => (bool) config('features.group_assets', true),
|
||||
'group_activity_feed' => (bool) config('features.group_activity_feed', true),
|
||||
],
|
||||
'studio_groups' => $request->user()
|
||||
? app(GroupService::class)->studioOptionsForUser($request->user())
|
||||
'studio_groups' => $user
|
||||
? app(GroupService::class)->studioOptionsForUser($user)
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class VerifyCsrfToken extends Middleware
|
||||
protected $except = [
|
||||
'chat_post',
|
||||
'chat_post/*',
|
||||
'api/art/*/view',
|
||||
// Apple Sign In removed — no special CSRF exception required
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Uploads;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Uploads\UploadTokenService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -13,6 +14,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
final class UploadFinishRequest extends FormRequest
|
||||
{
|
||||
private ?Artwork $artwork = null;
|
||||
private ?UploadBatchItem $batchItem = null;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -97,6 +99,22 @@ final class UploadFinishRequest extends FormRequest
|
||||
$this->denyAsNotFound();
|
||||
}
|
||||
|
||||
$batchItemId = (int) $this->input('batch_item_id');
|
||||
if ($batchItemId > 0) {
|
||||
$batchItem = UploadBatchItem::query()->find($batchItemId);
|
||||
if (! $batchItem || (int) $batchItem->user_id !== (int) $user->id) {
|
||||
$this->logUnauthorized('batch_item_not_owned_or_missing');
|
||||
$this->denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) ($batchItem->artwork_id ?? 0) > 0 && (int) $batchItem->artwork_id !== $artworkId) {
|
||||
$this->logUnauthorized('batch_item_artwork_mismatch');
|
||||
$this->denyAsNotFound();
|
||||
}
|
||||
|
||||
$this->batchItem = $batchItem;
|
||||
}
|
||||
|
||||
$this->artwork = $artwork;
|
||||
|
||||
return true;
|
||||
@@ -109,6 +127,7 @@ final class UploadFinishRequest extends FormRequest
|
||||
'artwork_id' => 'required|integer',
|
||||
'upload_token' => 'nullable|string|min:40|max:200',
|
||||
'file_name' => 'nullable|string|max:255',
|
||||
'batch_item_id' => 'nullable|integer|min:1',
|
||||
'archive_session_id' => 'nullable|uuid|different:session_id',
|
||||
'archive_file_name' => 'nullable|string|max:255',
|
||||
'additional_screenshot_sessions' => 'nullable|array|max:4',
|
||||
@@ -126,6 +145,11 @@ final class UploadFinishRequest extends FormRequest
|
||||
return $this->artwork;
|
||||
}
|
||||
|
||||
public function batchItem(): ?UploadBatchItem
|
||||
{
|
||||
return $this->batchItem;
|
||||
}
|
||||
|
||||
private function denyAsNotFound(): void
|
||||
{
|
||||
throw new NotFoundHttpException();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
@@ -24,12 +25,11 @@ class DeleteArtworkFromIndexJob implements ShouldQueue
|
||||
|
||||
public function __construct(public readonly int $artworkId) {}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(MeilisearchClient $client): void
|
||||
{
|
||||
// Create a bare model instance just to call unsearchable() with the right ID.
|
||||
$artwork = new Artwork();
|
||||
$artwork->id = $this->artworkId;
|
||||
$artwork->unsearchable();
|
||||
// Delete directly from the Meilisearch index — no Scout after_commit hop.
|
||||
$indexName = (new Artwork())->searchableAs();
|
||||
$client->index($indexName)->deleteDocument($this->artworkId);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $e): void
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
@@ -30,11 +31,12 @@ final class GenerateDerivativesJob implements ShouldQueue
|
||||
private readonly ?string $archiveSessionId = null,
|
||||
private readonly ?string $archiveHash = null,
|
||||
private readonly ?string $archiveOriginalFileName = null,
|
||||
private readonly array $additionalScreenshotSessions = []
|
||||
private readonly array $additionalScreenshotSessions = [],
|
||||
private readonly ?int $batchItemId = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(UploadPipelineService $pipeline): void
|
||||
public function handle(UploadPipelineService $pipeline, UploadQueueService $queue): void
|
||||
{
|
||||
$pipeline->processAndPublish(
|
||||
$this->sessionId,
|
||||
@@ -47,10 +49,27 @@ final class GenerateDerivativesJob implements ShouldQueue
|
||||
$this->additionalScreenshotSessions
|
||||
);
|
||||
|
||||
if ($this->batchItemId) {
|
||||
$queue->markItemMediaProcessed($this->batchItemId);
|
||||
}
|
||||
|
||||
// Auto-tagging is async and must never block publish.
|
||||
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
if (! $this->batchItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(UploadQueueService::class)->markItemFailed(
|
||||
$this->batchItemId,
|
||||
'derivatives_failed',
|
||||
$exception->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,35 +11,48 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
/**
|
||||
* Queued job: index (or re-index) a single Artwork in Meilisearch.
|
||||
*
|
||||
* Writes directly to the Meilisearch HTTP API instead of going through
|
||||
* Scout's searchable() / MakeSearchable pipeline. This avoids the
|
||||
* after_commit double-dispatch problem and ensures the document lands
|
||||
* in the index within this job's execution, with no extra queue hop.
|
||||
*/
|
||||
class IndexArtworkJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 30;
|
||||
public int $timeout = 60;
|
||||
|
||||
public function __construct(public readonly int $artworkId) {}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(MeilisearchClient $client): void
|
||||
{
|
||||
$artwork = Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
->find($this->artworkId);
|
||||
$artwork = Artwork::with([
|
||||
'user',
|
||||
'group',
|
||||
'tags',
|
||||
'categories.contentType',
|
||||
'stats',
|
||||
'awardStat',
|
||||
])->find($this->artworkId);
|
||||
|
||||
if (! $artwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $artwork->is_public || ! $artwork->is_approved || ! $artwork->published_at) {
|
||||
// Not public/approved — ensure it is removed from the index.
|
||||
$artwork->unsearchable();
|
||||
// Not eligible — remove from index if present.
|
||||
$client->index($artwork->searchableAs())->deleteDocument($this->artworkId);
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->searchable();
|
||||
$document = $artwork->toSearchableArray();
|
||||
$client->index($artwork->searchableAs())->addDocuments([$document]);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $e): void
|
||||
|
||||
@@ -28,6 +28,19 @@ class Artwork extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
/**
|
||||
* Override Scout's bootSearchable to skip the ModelObserver (which fires MakeSearchable
|
||||
* on every save). We still register SearchableScope and Builder macros so that
|
||||
* scout:import and Builder::searchable() continue to work.
|
||||
* All indexing is managed explicitly via IndexArtworkJob.
|
||||
*/
|
||||
public static function bootSearchable(): void
|
||||
{
|
||||
static::addGlobalScope(new \Laravel\Scout\SearchableScope);
|
||||
(new static)->registerSearchableMacros();
|
||||
// ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob.
|
||||
}
|
||||
|
||||
public const PUBLISHED_AS_USER = 'user';
|
||||
public const PUBLISHED_AS_GROUP = 'group';
|
||||
|
||||
|
||||
@@ -83,6 +83,6 @@ final class Country extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png';
|
||||
return rtrim((string) \config('cdn.files_url', ''), '/').'/images/flags/shiny/24/'.rawurlencode($iso2).'.png';
|
||||
}
|
||||
}
|
||||
|
||||
43
.deploy/artwork-evolution-release/app/Models/UploadBatch.php
Normal file
43
.deploy/artwork-evolution-release/app/Models/UploadBatch.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class UploadBatch extends Model
|
||||
{
|
||||
public const STATUS_UPLOADING = 'uploading';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_COMPLETED_WITH_ERRORS = 'completed_with_errors';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'status',
|
||||
'total_items',
|
||||
'processed_items',
|
||||
'failed_items',
|
||||
'published_items',
|
||||
'defaults_json',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'defaults_json' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(UploadBatchItem::class)->orderBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class UploadBatchItem extends Model
|
||||
{
|
||||
public const STATUS_UPLOADED = 'uploaded';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_NEEDS_METADATA = 'needs_metadata';
|
||||
public const STATUS_NEEDS_REVIEW = 'needs_review';
|
||||
public const STATUS_READY = 'ready';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_DELETED = 'deleted';
|
||||
|
||||
public const STAGE_QUEUED = 'queued';
|
||||
public const STAGE_STORED = 'stored';
|
||||
public const STAGE_THUMBNAILS = 'thumbnails';
|
||||
public const STAGE_VISION_ANALYSIS = 'vision_analysis';
|
||||
public const STAGE_MATURITY_CHECK = 'maturity_check';
|
||||
public const STAGE_METADATA_SUGGESTIONS = 'metadata_suggestions';
|
||||
public const STAGE_FINALIZED = 'finalized';
|
||||
|
||||
protected $fillable = [
|
||||
'upload_batch_id',
|
||||
'user_id',
|
||||
'artwork_id',
|
||||
'original_filename',
|
||||
'status',
|
||||
'processing_stage',
|
||||
'error_code',
|
||||
'error_message',
|
||||
'metadata_completeness',
|
||||
'is_ready_to_publish',
|
||||
'uploaded_at',
|
||||
'processed_at',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_ready_to_publish' => 'boolean',
|
||||
'uploaded_at' => 'datetime',
|
||||
'processed_at' => 'datetime',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function batch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UploadBatch::class, 'upload_batch_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
$displayName = null;
|
||||
$userId = null;
|
||||
$toolbarContentTypes = collect();
|
||||
$request = request();
|
||||
$canReadSessionAuth = $request instanceof \Illuminate\Http\Request
|
||||
&& $request->hasSession()
|
||||
&& $request->attributes->get('skinbase.session_skipped') !== true;
|
||||
$authUser = $canReadSessionAuth ? Auth::user() : null;
|
||||
|
||||
try {
|
||||
$toolbarContentTypes = $this->app
|
||||
@@ -162,8 +167,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
$toolbarContentTypes = collect();
|
||||
}
|
||||
|
||||
if (Auth::check()) {
|
||||
$userId = Auth::id();
|
||||
if ($authUser) {
|
||||
$authUser->loadMissing('profile');
|
||||
$userId = (int) $authUser->id;
|
||||
try {
|
||||
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
@@ -200,19 +206,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
try {
|
||||
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
|
||||
->unreadCountForUser(Auth::user());
|
||||
->unreadCountForUser($authUser);
|
||||
} catch (\Throwable $e) {
|
||||
$receivedCommentsCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
|
||||
$avatarHash = $profile->avatar_hash ?? null;
|
||||
$avatarHash = $authUser->profile?->avatar_hash;
|
||||
} catch (\Throwable $e) {
|
||||
$avatarHash = null;
|
||||
}
|
||||
|
||||
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
|
||||
$displayName = $authUser->name ?: ($authUser->username ?? '');
|
||||
}
|
||||
|
||||
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes'));
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Jobs\DeleteArtworkFromIndexJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -43,19 +44,63 @@ final class ArtworkSearchIndexer
|
||||
/**
|
||||
* Rebuild the entire artworks index in background chunks.
|
||||
* Run via: php artisan artworks:search-rebuild
|
||||
*
|
||||
* @param Closure(int, int, int, int, int, int): void|null $onChunk
|
||||
* @return array{total:int, dispatched:int, chunks:int}
|
||||
*/
|
||||
public function rebuildAll(int $chunkSize = 500): void
|
||||
public function rebuildAll(int $chunkSize = 500, ?Closure $onChunk = null, bool $reverse = false, ?int $limit = null): array
|
||||
{
|
||||
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($artworks): void {
|
||||
->published();
|
||||
|
||||
if ($reverse) {
|
||||
$query->orderByDesc('id');
|
||||
} else {
|
||||
$query->orderBy('id');
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
$dispatched = 0;
|
||||
$chunks = 0;
|
||||
|
||||
$query
|
||||
->with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
->chunk($chunkSize, function ($artworks) use (&$chunks, &$dispatched, $total, $onChunk): void {
|
||||
$chunks++;
|
||||
|
||||
$count = $artworks->count();
|
||||
$firstId = (int) ($artworks->first()?->id ?? 0);
|
||||
$lastId = (int) ($artworks->last()?->id ?? 0);
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
$dispatched++;
|
||||
}
|
||||
|
||||
if ($onChunk !== null) {
|
||||
$onChunk($chunks, $count, $dispatched, $total, $firstId, $lastId);
|
||||
}
|
||||
});
|
||||
|
||||
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
|
||||
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched', [
|
||||
'total' => $total,
|
||||
'dispatched' => $dispatched,
|
||||
'chunks' => $chunks,
|
||||
'chunk_size' => $chunkSize,
|
||||
'reverse' => $reverse,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'dispatched' => $dispatched,
|
||||
'chunks' => $chunks,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\Group;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
@@ -392,17 +393,6 @@ class GroupArtworkReviewService
|
||||
|
||||
private function syncSearchIndex(Artwork $artwork): void
|
||||
{
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to sync artwork search index for group review workflow', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ final class SitemapCacheService
|
||||
{
|
||||
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
|
||||
$segments = $name === self::INDEX_DOCUMENT
|
||||
? [$prefix, 'sitemap.xml']
|
||||
? [$prefix, 'sitemaps', 'sitemap.xml']
|
||||
: [$prefix, 'sitemaps', $name . '.xml'];
|
||||
|
||||
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
|
||||
|
||||
@@ -124,7 +124,7 @@ final class SitemapReleaseManager
|
||||
public function documentRelativePath(string $documentName): string
|
||||
{
|
||||
return $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? 'sitemap.xml'
|
||||
? 'sitemaps/sitemap.xml'
|
||||
: 'sitemaps/' . $documentName . '.xml';
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -156,12 +157,8 @@ final class StudioBulkActionService
|
||||
*/
|
||||
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
|
||||
{
|
||||
try {
|
||||
$artworks->each->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Failed to reindex artworks after bulk action', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
foreach ($artworks as $artwork) {
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UploadBatch;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Models\User;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\TagService;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class UploadQueueService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkDraftService $artworkDrafts,
|
||||
private readonly ArtworkPublicationService $publication,
|
||||
private readonly TagService $tags,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createBatch(User $user, array $files, array $defaults = [], ?string $name = null): UploadBatch
|
||||
{
|
||||
$normalizedFiles = collect($files)
|
||||
->map(fn (mixed $file): array => [
|
||||
'name' => trim((string) data_get($file, 'name', '')),
|
||||
])
|
||||
->filter(fn (array $file): bool => $file['name'] !== '')
|
||||
->values();
|
||||
|
||||
if ($normalizedFiles->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'files' => ['Choose at least one file for the upload queue.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($normalizedFiles->count() > 50) {
|
||||
throw ValidationException::withMessages([
|
||||
'files' => ['Bulk upload is limited to 50 files per batch in v1.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedDefaults = $this->normalizeDefaults($defaults);
|
||||
|
||||
$batch = DB::transaction(function () use ($user, $normalizedFiles, $normalizedDefaults, $name): UploadBatch {
|
||||
$batch = UploadBatch::query()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'name' => $this->normalizeBatchName($name, $normalizedFiles->count()),
|
||||
'status' => UploadBatch::STATUS_UPLOADING,
|
||||
'total_items' => $normalizedFiles->count(),
|
||||
'defaults_json' => $normalizedDefaults === [] ? null : $normalizedDefaults,
|
||||
]);
|
||||
|
||||
foreach ($normalizedFiles as $file) {
|
||||
$draft = $this->artworkDrafts->createDraft(
|
||||
$user,
|
||||
$this->titleFromFilename($file['name']),
|
||||
null,
|
||||
Arr::get($normalizedDefaults, 'category_id'),
|
||||
(bool) Arr::get($normalizedDefaults, 'is_mature', false),
|
||||
Arr::get($normalizedDefaults, 'group')
|
||||
);
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($draft->artworkId);
|
||||
|
||||
$artwork->forceFill([
|
||||
'visibility' => Arr::get($normalizedDefaults, 'visibility', Artwork::VISIBILITY_PUBLIC),
|
||||
'is_public' => false,
|
||||
'artwork_status' => 'draft',
|
||||
])->saveQuietly();
|
||||
|
||||
$defaultTags = (array) Arr::get($normalizedDefaults, 'tags', []);
|
||||
if ($defaultTags !== []) {
|
||||
$this->tags->syncStudioTags($artwork, $defaultTags);
|
||||
}
|
||||
|
||||
$batch->items()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'original_filename' => $file['name'],
|
||||
'status' => UploadBatchItem::STATUS_UPLOADED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_QUEUED,
|
||||
'metadata_completeness' => $this->metadataCompleteness($artwork, false, false),
|
||||
'is_ready_to_publish' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return $batch;
|
||||
});
|
||||
|
||||
return $this->refreshBatch($batch->id);
|
||||
}
|
||||
|
||||
public function listPayload(User $user, array $filters = []): array
|
||||
{
|
||||
$requestedBatchId = (int) ($filters['batch_id'] ?? 0);
|
||||
$statusFilter = Str::lower(trim((string) ($filters['status'] ?? 'all')));
|
||||
$sort = Str::lower(trim((string) ($filters['sort'] ?? 'newest')));
|
||||
|
||||
$recentBatches = UploadBatch::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->latest('id')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
$currentBatch = $requestedBatchId > 0
|
||||
? $recentBatches->firstWhere('id', $requestedBatchId)
|
||||
: $recentBatches->first();
|
||||
|
||||
if ($currentBatch) {
|
||||
$currentBatch = $this->refreshBatch((int) $currentBatch->id);
|
||||
}
|
||||
|
||||
$items = $currentBatch
|
||||
? $currentBatch->items
|
||||
->loadMissing(['artwork.categories.contentType', 'artwork.tags'])
|
||||
->map(fn (UploadBatchItem $item): array => $this->presentItem($this->refreshItem((int) $item->id)))
|
||||
: collect();
|
||||
|
||||
$filteredItems = $this->applyFiltersAndSorting($items, $statusFilter, $sort)->values();
|
||||
$recentBatchPayloads = $recentBatches
|
||||
->map(fn (UploadBatch $batch): UploadBatch => $batch->relationLoaded('items') ? $batch : $this->refreshBatch((int) $batch->id))
|
||||
->map(fn (UploadBatch $batch): array => $this->presentBatch($batch))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'batch_id' => $currentBatch?->id,
|
||||
'status' => $statusFilter,
|
||||
'sort' => $sort,
|
||||
],
|
||||
'batches' => $recentBatchPayloads,
|
||||
'current_batch' => $currentBatch ? $this->presentBatch($currentBatch) : null,
|
||||
'items' => $filteredItems->all(),
|
||||
'status_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => UploadBatchItem::STATUS_PROCESSING, 'label' => 'Processing'],
|
||||
['value' => UploadBatchItem::STATUS_NEEDS_METADATA, 'label' => 'Needs metadata'],
|
||||
['value' => UploadBatchItem::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
|
||||
['value' => UploadBatchItem::STATUS_READY, 'label' => 'Ready'],
|
||||
['value' => UploadBatchItem::STATUS_FAILED, 'label' => 'Failed'],
|
||||
['value' => UploadBatchItem::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'newest', 'label' => 'Newest first'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'filename', 'label' => 'Filename'],
|
||||
['value' => 'status', 'label' => 'Status'],
|
||||
['value' => 'ready', 'label' => 'Ready first'],
|
||||
['value' => 'failed', 'label' => 'Failed first'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function markItemProcessingQueued(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||
'processing_stage' => UploadBatchItem::STAGE_THUMBNAILS,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'uploaded_at' => $item->uploaded_at ?: now(),
|
||||
])->save();
|
||||
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function markItemMediaProcessed(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$refreshed = $this->refreshItem((int) $item->id);
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function markItemFailed(int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_FAILED,
|
||||
'error_code' => $this->nullableString($errorCode),
|
||||
'error_message' => $this->nullableText($errorMessage),
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function markItemFailedForUser(User $user, int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||
{
|
||||
$item = $this->ownedItems($user, [$itemId])->first();
|
||||
if (! $item) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Upload queue item not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->markItemFailed((int) $item->id, $errorCode, $errorMessage);
|
||||
}
|
||||
|
||||
public function refreshItem(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
$state = $this->evaluateItemState($item);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => $state['status'],
|
||||
'processing_stage' => $state['processing_stage'],
|
||||
'metadata_completeness' => $state['metadata_completeness'],
|
||||
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||
'published_at' => $state['published_at'],
|
||||
'processed_at' => $state['processed_at'],
|
||||
])->saveQuietly();
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function refreshBatch(int $batchId): UploadBatch
|
||||
{
|
||||
$batch = UploadBatch::query()
|
||||
->with(['items.artwork.categories.contentType', 'items.artwork.tags'])
|
||||
->findOrFail($batchId);
|
||||
|
||||
$items = $batch->items->map(fn (UploadBatchItem $item): UploadBatchItem => $this->refreshItem((int) $item->id));
|
||||
$summary = $this->summarizeItems($items);
|
||||
|
||||
$batch->forceFill([
|
||||
'status' => $summary['status'],
|
||||
'total_items' => $summary['total_items'],
|
||||
'processed_items' => $summary['processed_items'],
|
||||
'failed_items' => $summary['failed_items'],
|
||||
'published_items' => $summary['published_items'],
|
||||
])->saveQuietly();
|
||||
|
||||
return $batch->fresh(['items.artwork.categories.contentType', 'items.artwork.tags']) ?? $batch;
|
||||
}
|
||||
|
||||
public function bulkAction(User $user, string $action, array $itemIds, array $params = []): array
|
||||
{
|
||||
$items = $this->ownedItems($user, $itemIds);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return [
|
||||
'success' => 0,
|
||||
'failed' => count($itemIds),
|
||||
'errors' => ['No owned upload queue items were found.'],
|
||||
];
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
match ($action) {
|
||||
'publish' => $this->publishItem($item),
|
||||
'delete' => $this->deleteItem($item),
|
||||
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
|
||||
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
|
||||
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
|
||||
'generate_ai' => $this->retryProcessing($item),
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => ['Unsupported upload queue action.'],
|
||||
]),
|
||||
};
|
||||
|
||||
$success++;
|
||||
} catch (ValidationException $exception) {
|
||||
$failed++;
|
||||
$errors[] = collect($exception->errors())->flatten()->first() ?: 'Action failed.';
|
||||
} catch (\Throwable $exception) {
|
||||
$failed++;
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$batchIds = $items->pluck('upload_batch_id')->unique()->filter()->all();
|
||||
foreach ($batchIds as $batchId) {
|
||||
$this->refreshBatch((int) $batchId);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
public function retryProcessingForUser(User $user, int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->ownedItems($user, [$itemId])->first();
|
||||
if (! $item) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Upload queue item not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->retryProcessing($item);
|
||||
}
|
||||
|
||||
private function retryProcessing(UploadBatchItem $item): UploadBatchItem
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['This item cannot be retried safely. Re-upload the original file instead.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
|
||||
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
|
||||
|
||||
return $this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function publishItem(UploadBatchItem $item): void
|
||||
{
|
||||
$item = $this->refreshItem((int) $item->id);
|
||||
if (! $item->is_ready_to_publish || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Only ready queue items can be published.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$artwork = $item->artwork;
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||
])->saveQuietly();
|
||||
|
||||
$this->publication->publishNow($artwork->fresh() ?? $artwork);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PUBLISHED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||
'is_ready_to_publish' => false,
|
||||
'published_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function deleteItem(UploadBatchItem $item): void
|
||||
{
|
||||
if ($item->artwork && ! $item->artwork->trashed()) {
|
||||
$item->artwork->delete();
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_DELETED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function applyCategory(UploadBatchItem $item, int $categoryId): void
|
||||
{
|
||||
if ($categoryId <= 0 || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'category_id' => ['Choose a valid category.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->categories()->sync([$categoryId]);
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function applyTags(UploadBatchItem $item, array $tags): void
|
||||
{
|
||||
if (! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Artwork not found for this queue item.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedTags = collect($tags)
|
||||
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($normalizedTags->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Enter at least one tag to apply.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$mergedTags = collect($item->artwork->tags()->pluck('tags.slug')->all())
|
||||
->merge($normalizedTags)
|
||||
->map(fn (string $tag): string => trim($tag))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->tags->syncStudioTags($item->artwork->fresh(['tags']) ?? $item->artwork, $mergedTags);
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function setVisibility(UploadBatchItem $item, string $visibility): void
|
||||
{
|
||||
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true) || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'visibility' => ['Choose a valid visibility.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->forceFill([
|
||||
'visibility' => $visibility,
|
||||
'is_public' => false,
|
||||
])->saveQuietly();
|
||||
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function ownedItems(User $user, array $itemIds): EloquentCollection
|
||||
{
|
||||
$normalizedIds = collect($itemIds)
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->filter(fn (int $id): bool => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->itemQuery()
|
||||
->where('user_id', (int) $user->id)
|
||||
->whereIn('id', $normalizedIds)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function itemQuery()
|
||||
{
|
||||
return UploadBatchItem::query()->with(['artwork.categories.contentType', 'artwork.tags']);
|
||||
}
|
||||
|
||||
private function applyFiltersAndSorting(Collection $items, string $statusFilter, string $sort): Collection
|
||||
{
|
||||
$filtered = $statusFilter === 'all'
|
||||
? $items
|
||||
: $items->filter(fn (array $item): bool => (string) ($item['status'] ?? '') === $statusFilter);
|
||||
|
||||
return match ($sort) {
|
||||
'oldest' => $filtered->sortBy('created_at'),
|
||||
'filename' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['original_filename'] ?? ''))),
|
||||
'status' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['status'] ?? ''))),
|
||||
'ready' => $filtered->sortByDesc(fn (array $item): int => (int) ($item['is_ready_to_publish'] ?? false)),
|
||||
'failed' => $filtered->sortByDesc(fn (array $item): int => (int) ((string) ($item['status'] ?? '') === UploadBatchItem::STATUS_FAILED)),
|
||||
default => $filtered->sortByDesc('created_at'),
|
||||
};
|
||||
}
|
||||
|
||||
private function summarizeItems(EloquentCollection $items): array
|
||||
{
|
||||
$statusCounts = $items->groupBy('status')->map->count();
|
||||
$activeCount = (int) ($statusCounts[UploadBatchItem::STATUS_UPLOADED] ?? 0)
|
||||
+ (int) ($statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0);
|
||||
$failedCount = (int) ($statusCounts[UploadBatchItem::STATUS_FAILED] ?? 0);
|
||||
$publishedCount = (int) ($statusCounts[UploadBatchItem::STATUS_PUBLISHED] ?? 0);
|
||||
$processedCount = $items->filter(fn (UploadBatchItem $item): bool => in_array((string) $item->status, [
|
||||
UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||
UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
UploadBatchItem::STATUS_READY,
|
||||
UploadBatchItem::STATUS_FAILED,
|
||||
UploadBatchItem::STATUS_PUBLISHED,
|
||||
UploadBatchItem::STATUS_DELETED,
|
||||
], true))->count();
|
||||
|
||||
$status = UploadBatch::STATUS_COMPLETED;
|
||||
if ($activeCount > 0) {
|
||||
$status = $statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0
|
||||
? UploadBatch::STATUS_PROCESSING
|
||||
: UploadBatch::STATUS_UPLOADING;
|
||||
} elseif ($failedCount > 0) {
|
||||
$status = UploadBatch::STATUS_COMPLETED_WITH_ERRORS;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'total_items' => $items->count(),
|
||||
'processed_items' => $processedCount,
|
||||
'failed_items' => $failedCount,
|
||||
'published_items' => $publishedCount,
|
||||
'ready_items' => (int) ($statusCounts[UploadBatchItem::STATUS_READY] ?? 0),
|
||||
'processing_items' => $activeCount,
|
||||
'needs_metadata_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_METADATA] ?? 0),
|
||||
'needs_review_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_REVIEW] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function evaluateItemState(UploadBatchItem $item): array
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
$isDeleted = (string) $item->status === UploadBatchItem::STATUS_DELETED || ($artwork?->trashed() ?? false);
|
||||
$isPublished = $artwork
|
||||
&& (string) ($artwork->artwork_status ?? '') === 'published'
|
||||
&& $artwork->published_at !== null;
|
||||
$hasProcessedMedia = $artwork
|
||||
&& trim((string) ($artwork->file_name ?? '')) !== ''
|
||||
&& trim((string) ($artwork->file_name ?? '')) !== 'pending'
|
||||
&& trim((string) ($artwork->file_path ?? '')) !== ''
|
||||
&& trim((string) ($artwork->hash ?? '')) !== '';
|
||||
$title = trim((string) ($artwork?->title ?? ''));
|
||||
$hasTitle = $title !== '';
|
||||
$hasCategory = (bool) $artwork?->categories?->first();
|
||||
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
|
||||
$visionEnabled = (bool) config('vision.enabled', true);
|
||||
|
||||
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
|
||||
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||
], true);
|
||||
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
|
||||
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
|
||||
$needsMetadata = ! $hasTitle || ! $hasCategory;
|
||||
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
|
||||
$isReadyToPublish = ! $isDeleted
|
||||
&& ! $isPublished
|
||||
&& ! $blockingUploadFailure
|
||||
&& $hasProcessedMedia
|
||||
&& ! $maturityPending
|
||||
&& ! $needsReview
|
||||
&& ! $needsMetadata
|
||||
&& $artwork
|
||||
&& (int) $artwork->user_id === (int) $item->user_id;
|
||||
|
||||
$status = match (true) {
|
||||
$isDeleted => UploadBatchItem::STATUS_DELETED,
|
||||
$isPublished => UploadBatchItem::STATUS_PUBLISHED,
|
||||
$blockingUploadFailure => UploadBatchItem::STATUS_FAILED,
|
||||
! $hasProcessedMedia || $maturityPending => UploadBatchItem::STATUS_PROCESSING,
|
||||
$needsReview => UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
$needsMetadata => UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||
$isReadyToPublish => UploadBatchItem::STATUS_READY,
|
||||
default => UploadBatchItem::STATUS_PROCESSING,
|
||||
};
|
||||
|
||||
$processingStage = match (true) {
|
||||
in_array($status, [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_FAILED], true) => UploadBatchItem::STAGE_FINALIZED,
|
||||
! $hasProcessedMedia && (string) $item->processing_stage === UploadBatchItem::STAGE_QUEUED => UploadBatchItem::STAGE_QUEUED,
|
||||
! $hasProcessedMedia => UploadBatchItem::STAGE_THUMBNAILS,
|
||||
$maturityPending => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
in_array($aiStatus, ['queued', 'processing'], true) => UploadBatchItem::STAGE_METADATA_SUGGESTIONS,
|
||||
default => UploadBatchItem::STAGE_FINALIZED,
|
||||
};
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'processing_stage' => $processingStage,
|
||||
'metadata_completeness' => $this->metadataCompleteness($artwork, $hasProcessedMedia, ! $maturityPending && ! $maturityFailed),
|
||||
'is_ready_to_publish' => $isReadyToPublish,
|
||||
'published_at' => $isPublished ? ($item->published_at ?: now()) : null,
|
||||
'processed_at' => $hasProcessedMedia ? ($item->processed_at ?: now()) : $item->processed_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function presentBatch(UploadBatch $batch): array
|
||||
{
|
||||
$summary = $this->summarizeItems($batch->items);
|
||||
|
||||
return [
|
||||
'id' => (int) $batch->id,
|
||||
'name' => $batch->name,
|
||||
'status' => $summary['status'],
|
||||
'total_items' => $summary['total_items'],
|
||||
'processed_items' => $summary['processed_items'],
|
||||
'failed_items' => $summary['failed_items'],
|
||||
'published_items' => $summary['published_items'],
|
||||
'ready_items' => $summary['ready_items'],
|
||||
'processing_items' => $summary['processing_items'],
|
||||
'needs_metadata_items' => $summary['needs_metadata_items'],
|
||||
'needs_review_items' => $summary['needs_review_items'],
|
||||
'defaults' => $batch->defaults_json ?? [],
|
||||
'created_at' => $this->iso($batch->created_at),
|
||||
'updated_at' => $this->iso($batch->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function presentItem(UploadBatchItem $item): array
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
$state = $this->evaluateItemState($item);
|
||||
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||
$missing = [];
|
||||
|
||||
if (! $artwork || trim((string) ($artwork->file_path ?? '')) === '' || trim((string) ($artwork->hash ?? '')) === '') {
|
||||
$missing[] = 'Processing incomplete';
|
||||
}
|
||||
if (! $artwork || trim((string) ($artwork->title ?? '')) === '') {
|
||||
$missing[] = 'Missing title';
|
||||
}
|
||||
if (! $artwork?->categories?->first()) {
|
||||
$missing[] = 'Missing category';
|
||||
}
|
||||
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
|
||||
$missing[] = 'Needs maturity review';
|
||||
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
|
||||
$missing[] = 'Maturity analysis pending';
|
||||
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
|
||||
$missing[] = 'Maturity check failed';
|
||||
}
|
||||
|
||||
$canRetry = ! empty($artwork?->hash)
|
||||
&& ! empty($artwork?->file_path)
|
||||
&& ($state['status'] === UploadBatchItem::STATUS_FAILED || $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED);
|
||||
|
||||
return [
|
||||
'id' => (int) $item->id,
|
||||
'batch_id' => (int) $item->upload_batch_id,
|
||||
'artwork_id' => $artwork?->id,
|
||||
'title' => $artwork?->title ?: $this->titleFromFilename((string) $item->original_filename),
|
||||
'original_filename' => $item->original_filename,
|
||||
'status' => $state['status'],
|
||||
'processing_stage' => $state['processing_stage'],
|
||||
'metadata_completeness' => $state['metadata_completeness'],
|
||||
'metadata_label' => $state['metadata_completeness'] . '% complete',
|
||||
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||
'error_code' => $item->error_code,
|
||||
'error_message' => $item->error_message,
|
||||
'missing' => $missing,
|
||||
'thumbnail_url' => $artwork?->thumbUrl('sm'),
|
||||
'visibility' => $artwork?->visibility,
|
||||
'maturity_status' => $maturityStatus,
|
||||
'maturity_ai_status' => $maturityAiStatus,
|
||||
'ai_status' => Str::lower((string) ($artwork?->ai_status ?? '')) ?: null,
|
||||
'created_at' => $this->iso($item->created_at),
|
||||
'updated_at' => $this->iso($item->updated_at),
|
||||
'published_at' => $this->iso($item->published_at),
|
||||
'edit_url' => $artwork ? route('studio.artworks.edit', ['id' => $artwork->id]) : null,
|
||||
'public_url' => $artwork && $artwork->published_at ? route('art.show', ['id' => $artwork->id]) : null,
|
||||
'actions' => [
|
||||
'can_edit' => $artwork !== null && $state['status'] !== UploadBatchItem::STATUS_DELETED,
|
||||
'can_publish' => $state['is_ready_to_publish'],
|
||||
'can_delete' => ! in_array($state['status'], [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED], true),
|
||||
'can_retry_processing' => $canRetry,
|
||||
'can_generate_ai' => $artwork !== null && trim((string) ($artwork->hash ?? '')) !== '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function metadataCompleteness(?Artwork $artwork, bool $hasProcessedMedia, bool $maturityReady): int
|
||||
{
|
||||
$checks = [
|
||||
$hasProcessedMedia,
|
||||
trim((string) ($artwork?->title ?? '')) !== '',
|
||||
(bool) $artwork?->categories?->first(),
|
||||
$maturityReady,
|
||||
];
|
||||
|
||||
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
|
||||
}
|
||||
|
||||
private function normalizeDefaults(array $defaults): array
|
||||
{
|
||||
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);
|
||||
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true)) {
|
||||
$visibility = Artwork::VISIBILITY_PUBLIC;
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'category_id' => ($categoryId = (int) ($defaults['category_id'] ?? 0)) > 0 ? $categoryId : null,
|
||||
'tags' => collect((array) ($defaults['tags'] ?? []))
|
||||
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'visibility' => $visibility,
|
||||
'is_mature' => (bool) ($defaults['is_mature'] ?? false),
|
||||
'group' => $this->nullableString($defaults['group'] ?? null),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
private function titleFromFilename(string $filename): string
|
||||
{
|
||||
$base = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$normalized = Str::of($base)
|
||||
->replace(['_', '-'], ' ')
|
||||
->squish()
|
||||
->trim();
|
||||
|
||||
return (string) ($normalized !== '' ? Str::limit((string) $normalized, 255, '') : 'Untitled artwork');
|
||||
}
|
||||
|
||||
private function normalizeBatchName(?string $name, int $count): string
|
||||
{
|
||||
$normalized = trim((string) $name);
|
||||
if ($normalized !== '') {
|
||||
return Str::limit($normalized, 160, '');
|
||||
}
|
||||
|
||||
return 'Upload Queue ' . now()->format('M j, Y g:i A') . ' (' . $count . ')';
|
||||
}
|
||||
|
||||
private function iso(CarbonInterface|string|null $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function nullableText(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ConditionalShareErrorsFromSession;
|
||||
use App\Http\Middleware\ConditionalStartSession;
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -13,9 +19,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->web(replace: [
|
||||
StartSession::class => ConditionalStartSession::class,
|
||||
ShareErrorsFromSession::class => ConditionalShareErrorsFromSession::class,
|
||||
ValidateCsrfToken::class => ConditionalValidateCsrfToken::class,
|
||||
]);
|
||||
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'chat_post',
|
||||
'chat_post/*',
|
||||
'api/art/*/view',
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
|
||||
@@ -13,7 +13,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
'name' => env('APP_NAME', 'SkinbaseNova'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -52,7 +52,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
'url' => env('APP_URL', 'https://skinbase.org'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => 'Europe/Ljubljana',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
'default' => env('DB_CONNECTION', 'mysql'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -198,6 +198,19 @@ return [
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'sessions' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_SESSION_DB', '2'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -201,7 +201,7 @@ return [
|
||||
'defaults' => [
|
||||
'supervisor-default' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default'],
|
||||
'queue' => ['search', 'default'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
|
||||
124
.deploy/artwork-evolution-release/config/skinbase-sessions.php
Normal file
124
.deploy/artwork-evolution-release/config/skinbase-sessions.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'enabled' => env('SKINBASE_CONDITIONAL_SESSIONS_ENABLED', true),
|
||||
|
||||
'skip_anonymous_public_get' => env('SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS', true),
|
||||
|
||||
'skip_known_crawlers_on_public_get' => env('SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS', true),
|
||||
|
||||
'debug_header' => env('SKINBASE_SESSION_DEBUG_HEADER', false),
|
||||
|
||||
'public_paths' => [
|
||||
'/',
|
||||
'featured',
|
||||
'uploads/latest',
|
||||
'uploads/daily',
|
||||
'members/photos',
|
||||
'downloads/today',
|
||||
'comments/monthly',
|
||||
'discover',
|
||||
'discover/*',
|
||||
'explore',
|
||||
'explore/*',
|
||||
'blog',
|
||||
'blog/*',
|
||||
'pages/*',
|
||||
'about',
|
||||
'help',
|
||||
'help/*',
|
||||
'contact',
|
||||
'faq',
|
||||
'rules-and-guidelines',
|
||||
'privacy-policy',
|
||||
'terms-of-service',
|
||||
'staff',
|
||||
'bug-report',
|
||||
'rss-feeds',
|
||||
'rss',
|
||||
'rss/*',
|
||||
'news',
|
||||
'news/*',
|
||||
'worlds',
|
||||
'worlds/*',
|
||||
'creators',
|
||||
'creators/*',
|
||||
'stories',
|
||||
'stories/*',
|
||||
'tags',
|
||||
'tags/*',
|
||||
'categories',
|
||||
'leaderboard',
|
||||
'art',
|
||||
'art/*',
|
||||
'sitemap.xml',
|
||||
'sitemaps/*',
|
||||
'robots.txt',
|
||||
],
|
||||
|
||||
'always_session_paths' => [
|
||||
'login',
|
||||
'logout',
|
||||
'register',
|
||||
'register/*',
|
||||
'auth/*',
|
||||
'forgot-password',
|
||||
'reset-password',
|
||||
'reset-password/*',
|
||||
'confirm-password',
|
||||
'email/verification-notification',
|
||||
'verify-email',
|
||||
'verify-email/*',
|
||||
'setup/*',
|
||||
|
||||
'dashboard',
|
||||
'dashboard/*',
|
||||
'manage',
|
||||
'studio',
|
||||
'studio/*',
|
||||
'upload',
|
||||
'upload/*',
|
||||
'settings',
|
||||
'settings/*',
|
||||
'messages',
|
||||
'messages/*',
|
||||
'worlds/create',
|
||||
|
||||
'cp',
|
||||
'cp/*',
|
||||
'admin',
|
||||
'admin/*',
|
||||
|
||||
'api/me',
|
||||
'api/auth/*',
|
||||
],
|
||||
|
||||
'bot_user_agent_keywords' => [
|
||||
'googlebot',
|
||||
'bingbot',
|
||||
'slurp',
|
||||
'duckduckbot',
|
||||
'baiduspider',
|
||||
'yandexbot',
|
||||
'sogou',
|
||||
'exabot',
|
||||
'facebot',
|
||||
'facebookexternalhit',
|
||||
'ia_archiver',
|
||||
'semrushbot',
|
||||
'ahrefsbot',
|
||||
'mj12bot',
|
||||
'dotbot',
|
||||
'petalbot',
|
||||
'applebot',
|
||||
'twitterbot',
|
||||
'linkedinbot',
|
||||
'discordbot',
|
||||
'telegrambot',
|
||||
'whatsapp',
|
||||
'crawler',
|
||||
'spider',
|
||||
'bot',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('upload_batches', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 160)->nullable();
|
||||
$table->string('status', 32)->default('uploading')->index();
|
||||
$table->unsignedInteger('total_items')->default(0);
|
||||
$table->unsignedInteger('processed_items')->default(0);
|
||||
$table->unsignedInteger('failed_items')->default(0);
|
||||
$table->unsignedInteger('published_items')->default(0);
|
||||
$table->json('defaults_json')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'created_at'], 'upload_batches_user_created_idx');
|
||||
});
|
||||
|
||||
Schema::create('upload_batch_items', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->foreignId('upload_batch_id')->constrained('upload_batches')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
|
||||
$table->string('original_filename');
|
||||
$table->string('status', 32)->default('uploaded')->index();
|
||||
$table->string('processing_stage', 32)->default('queued')->index();
|
||||
$table->string('error_code', 64)->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->unsignedTinyInteger('metadata_completeness')->default(0);
|
||||
$table->boolean('is_ready_to_publish')->default(false)->index();
|
||||
$table->timestamp('uploaded_at')->nullable();
|
||||
$table->timestamp('processed_at')->nullable();
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['upload_batch_id', 'status'], 'upload_batch_items_batch_status_idx');
|
||||
$table->index(['user_id', 'status'], 'upload_batch_items_user_status_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('upload_batch_items');
|
||||
Schema::dropIfExists('upload_batches');
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
[program:skinbase-queue]
|
||||
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
|
||||
command=/usr/bin/php /var/www/SkinbaseNova/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/skinbase/queue.log
|
||||
stdout_logfile=/var/log/skinbase_queue.log
|
||||
stopwaitsecs=3600
|
||||
|
||||
@@ -7,8 +7,8 @@ User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
WorkingDirectory=/var/www/skinbase
|
||||
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
|
||||
WorkingDirectory=/opt/www/virtual/SkinbaseNova
|
||||
ExecStart=/usr/bin/php /opt/www/virtual/SkinbaseNova/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=skinbase-queue
|
||||
|
||||
@@ -15,6 +15,7 @@ const baseNavGroups = [
|
||||
label: 'Create',
|
||||
items: [
|
||||
{ label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'Upload Queue', href: '/studio/upload-queue', icon: 'fa-solid fa-layer-group' },
|
||||
{ label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
|
||||
@@ -34,6 +35,7 @@ const baseNavGroups = [
|
||||
label: 'Library',
|
||||
items: [
|
||||
{ label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' },
|
||||
{ label: 'Upload Queue', href: '/studio/upload-queue', icon: 'fa-solid fa-list-check' },
|
||||
{ label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' },
|
||||
{ label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' },
|
||||
{ label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' },
|
||||
@@ -168,25 +170,40 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
|
||||
const currentGroup = props.studioGroup || null
|
||||
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
|
||||
const canManageWorlds = canManageNews
|
||||
|
||||
const navGroups = baseNavGroups.map((group) => {
|
||||
if (!canManageNews || group.label !== 'Content') {
|
||||
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
|
||||
return group
|
||||
}
|
||||
|
||||
const extraItems = []
|
||||
|
||||
if (canManageNews) {
|
||||
extraItems.push({ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' })
|
||||
}
|
||||
|
||||
if (canManageWorlds) {
|
||||
extraItems.push({ label: 'Worlds', href: '/studio/worlds', icon: 'fa-solid fa-globe' })
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
items: [
|
||||
...group.items,
|
||||
{ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' },
|
||||
],
|
||||
items: [...group.items, ...extraItems],
|
||||
}
|
||||
})
|
||||
|
||||
const quickCreateItems = (canManageNews
|
||||
? [...baseQuickCreateItems, { label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' }]
|
||||
: baseQuickCreateItems
|
||||
).map((item) => {
|
||||
const quickCreatePool = [...baseQuickCreateItems]
|
||||
|
||||
if (canManageNews) {
|
||||
quickCreatePool.push({ label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' })
|
||||
}
|
||||
|
||||
if (canManageWorlds) {
|
||||
quickCreatePool.push({ label: 'World', href: '/studio/worlds/create', icon: 'fa-solid fa-globe' })
|
||||
}
|
||||
|
||||
const quickCreateItems = quickCreatePool.map((item) => {
|
||||
if (currentGroup?.urls && item.label === 'Artwork') {
|
||||
return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href }
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
|
||||
], [stats])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="Artwork Maturity Queue" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
const MIN_CHUNK_SIZE_BYTES = 256 * 1024
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Just now'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Just now'
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const normalized = Number(value || 0)
|
||||
if (!Number.isFinite(normalized)) return '0%'
|
||||
return `${Math.max(0, Math.min(100, Math.round(normalized)))}%`
|
||||
}
|
||||
|
||||
function parseTags(raw) {
|
||||
return String(raw || '')
|
||||
.split(/[\n,]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function statusClasses(status) {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'published':
|
||||
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||
case 'failed':
|
||||
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
|
||||
case 'needs_review':
|
||||
case 'needs_metadata':
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
default:
|
||||
return 'border-white/15 bg-white/5 text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
function batchStatusClasses(status) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'completed_with_errors':
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
case 'processing':
|
||||
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||
default:
|
||||
return 'border-white/15 bg-white/5 text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
function noticeClasses(type) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'warning':
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
default:
|
||||
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
|
||||
}
|
||||
}
|
||||
|
||||
function humanStage(stage) {
|
||||
return String(stage || 'queued').replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function flattenCategories(contentTypes) {
|
||||
return (Array.isArray(contentTypes) ? contentTypes : []).flatMap((type) => {
|
||||
const parents = Array.isArray(type?.categories) ? type.categories : []
|
||||
return parents.flatMap((category) => {
|
||||
const children = Array.isArray(category?.children) ? category.children : []
|
||||
if (children.length === 0) {
|
||||
return [{
|
||||
id: category.id,
|
||||
label: `${type.name} / ${category.name}`,
|
||||
}]
|
||||
}
|
||||
|
||||
return children.map((child) => ({
|
||||
id: child.id,
|
||||
label: `${type.name} / ${category.name} / ${child.name}`,
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, hint }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioUploadQueue() {
|
||||
const { props } = usePage()
|
||||
const queueProp = props.queue || {}
|
||||
const chunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(props.chunkSize || 0) || (5 * 1024 * 1024))
|
||||
const chunkRequestTimeoutMs = Math.max(15000, Number(props.chunkRequestTimeoutMs || 0) || 45000)
|
||||
const categoryOptions = useMemo(() => flattenCategories(props.contentTypes || []), [props.contentTypes])
|
||||
|
||||
const [queue, setQueue] = useState(queueProp)
|
||||
const [selectedBatchId, setSelectedBatchId] = useState(queueProp?.filters?.batch_id ?? queueProp?.current_batch?.id ?? '')
|
||||
const [statusFilter, setStatusFilter] = useState(queueProp?.filters?.status || 'all')
|
||||
const [sort, setSort] = useState(queueProp?.filters?.sort || 'newest')
|
||||
const [selectedIds, setSelectedIds] = useState([])
|
||||
const [files, setFiles] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadState, setUploadState] = useState({})
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busyAction, setBusyAction] = useState('')
|
||||
const [defaults, setDefaults] = useState({
|
||||
name: '',
|
||||
categoryId: '',
|
||||
tags: '',
|
||||
visibility: 'public',
|
||||
isMature: false,
|
||||
})
|
||||
const [bulkForm, setBulkForm] = useState({
|
||||
categoryId: '',
|
||||
tags: '',
|
||||
visibility: 'public',
|
||||
})
|
||||
const fileInputRef = useRef(null)
|
||||
const noticeTimeoutRef = useRef(null)
|
||||
|
||||
const items = Array.isArray(queue?.items) ? queue.items : []
|
||||
const currentBatch = queue?.current_batch || null
|
||||
const batches = Array.isArray(queue?.batches) ? queue.batches : []
|
||||
const selectableIds = items
|
||||
.filter((item) => item?.actions?.can_delete || item?.actions?.can_publish || item?.actions?.can_generate_ai)
|
||||
.map((item) => Number(item.id))
|
||||
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
|
||||
const activeProcessing = uploading || ['uploading', 'processing'].includes(String(currentBatch?.status || ''))
|
||||
|
||||
const pushNotice = (type, message) => {
|
||||
setNotice({ type, message })
|
||||
window.clearTimeout(noticeTimeoutRef.current)
|
||||
noticeTimeoutRef.current = window.setTimeout(() => setNotice(null), 4500)
|
||||
}
|
||||
|
||||
useEffect(() => () => window.clearTimeout(noticeTimeoutRef.current), [])
|
||||
|
||||
const syncSelectedIds = (queueItems) => {
|
||||
const validIds = new Set((queueItems || []).map((item) => Number(item.id)))
|
||||
setSelectedIds((current) => current.filter((id) => validIds.has(id)))
|
||||
}
|
||||
|
||||
const loadQueue = async (overrides = {}) => {
|
||||
const params = {
|
||||
batch_id: overrides.batch_id ?? (selectedBatchId || undefined),
|
||||
status: overrides.status ?? statusFilter,
|
||||
sort: overrides.sort ?? sort,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.axios.get('/api/studio/upload-queue', { params })
|
||||
const nextQueue = response.data || {}
|
||||
setQueue(nextQueue)
|
||||
setSelectedBatchId(nextQueue?.filters?.batch_id ?? '')
|
||||
syncSelectedIds(nextQueue?.items || [])
|
||||
return nextQueue
|
||||
} catch (error) {
|
||||
pushNotice('error', error?.response?.data?.message || 'Failed to refresh the upload queue.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProcessing || !selectedBatchId) return undefined
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
loadQueue({ batch_id: selectedBatchId })
|
||||
}, 3000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [activeProcessing, selectedBatchId, statusFilter, sort])
|
||||
|
||||
const uploadChunk = async (sessionId, uploadToken, blob, offset, totalSize) => {
|
||||
const payload = new FormData()
|
||||
payload.append('session_id', sessionId)
|
||||
payload.append('offset', String(offset))
|
||||
payload.append('chunk_size', String(blob.size))
|
||||
payload.append('total_size', String(totalSize))
|
||||
payload.append('chunk', blob)
|
||||
payload.append('upload_token', uploadToken)
|
||||
|
||||
const response = await window.axios.post('/api/uploads/chunk', payload, {
|
||||
timeout: chunkRequestTimeoutMs,
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
})
|
||||
|
||||
return response.data || {}
|
||||
}
|
||||
|
||||
const uploadSingleFile = async (item, file) => {
|
||||
const init = await window.axios.post('/api/uploads/init', { client: 'web' })
|
||||
const sessionId = init?.data?.session_id
|
||||
const uploadToken = init?.data?.upload_token
|
||||
if (!sessionId || !uploadToken) {
|
||||
throw new Error('Upload session initialization failed.')
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
while (offset < file.size) {
|
||||
const nextOffset = Math.min(offset + chunkSize, file.size)
|
||||
const chunk = file.slice(offset, nextOffset)
|
||||
const data = await uploadChunk(sessionId, uploadToken, chunk, offset, file.size)
|
||||
offset = Number(data?.received_bytes ?? nextOffset)
|
||||
const progress = Math.max(1, Math.min(100, Math.round((offset / file.size) * 100)))
|
||||
setUploadState((current) => ({
|
||||
...current,
|
||||
[item.id]: {
|
||||
...current[item.id],
|
||||
status: 'uploading',
|
||||
progress,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
await window.axios.post('/api/uploads/finish', {
|
||||
session_id: sessionId,
|
||||
artwork_id: item.artwork_id,
|
||||
batch_item_id: item.id,
|
||||
file_name: file.name,
|
||||
upload_token: uploadToken,
|
||||
}, {
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
})
|
||||
|
||||
setUploadState((current) => ({
|
||||
...current,
|
||||
[item.id]: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const markItemFailed = async (itemId, error) => {
|
||||
try {
|
||||
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/fail`, {
|
||||
error_code: error?.response?.data?.reason || 'upload_failed',
|
||||
error_message: error?.response?.data?.message || error?.message || 'Upload failed.',
|
||||
})
|
||||
} catch (markError) {
|
||||
// Keep the original upload error as the visible one.
|
||||
}
|
||||
}
|
||||
|
||||
const startUpload = async () => {
|
||||
if (files.length === 0) {
|
||||
pushNotice('error', 'Choose at least one image file to start a batch.')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setBusyAction('create-batch')
|
||||
|
||||
try {
|
||||
const response = await window.axios.post('/api/studio/upload-queue/batches', {
|
||||
name: defaults.name || null,
|
||||
files: files.map((file) => ({ name: file.name })),
|
||||
defaults: {
|
||||
category_id: defaults.categoryId ? Number(defaults.categoryId) : null,
|
||||
tags: parseTags(defaults.tags),
|
||||
visibility: defaults.visibility,
|
||||
is_mature: Boolean(defaults.isMature),
|
||||
},
|
||||
})
|
||||
|
||||
const createdItems = Array.isArray(response?.data?.items) ? response.data.items : []
|
||||
const batchId = response?.data?.batch?.id
|
||||
if (!batchId || createdItems.length !== files.length) {
|
||||
throw new Error('Batch registration did not return a usable file map.')
|
||||
}
|
||||
|
||||
setQueue(response.data.queue || queue)
|
||||
setSelectedBatchId(batchId)
|
||||
setSelectedIds([])
|
||||
|
||||
for (let index = 0; index < createdItems.length; index += 1) {
|
||||
const item = createdItems[index]
|
||||
const file = files[index]
|
||||
|
||||
setUploadState((current) => ({
|
||||
...current,
|
||||
[item.id]: { status: 'queued', progress: 0 },
|
||||
}))
|
||||
|
||||
try {
|
||||
await uploadSingleFile(item, file)
|
||||
} catch (error) {
|
||||
await markItemFailed(item.id, error)
|
||||
setUploadState((current) => ({
|
||||
...current,
|
||||
[item.id]: {
|
||||
status: 'failed',
|
||||
progress: current[item.id]?.progress || 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
await loadQueue({ batch_id: batchId })
|
||||
}
|
||||
|
||||
setFiles([])
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
pushNotice('success', 'Upload batch created. Processing continues in the queue.')
|
||||
} catch (error) {
|
||||
pushNotice('error', error?.response?.data?.message || error?.message || 'Failed to create the upload batch.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setBusyAction('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds([])
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedIds(selectableIds)
|
||||
}
|
||||
|
||||
const handleToggleSelected = (itemId) => {
|
||||
setSelectedIds((current) => current.includes(itemId)
|
||||
? current.filter((id) => id !== itemId)
|
||||
: [...current, itemId])
|
||||
}
|
||||
|
||||
const summarizePublishSelection = (ids) => {
|
||||
const selectedItems = items.filter((item) => ids.includes(Number(item.id)))
|
||||
const readyItems = selectedItems.filter((item) => item?.is_ready_to_publish)
|
||||
const blockedItems = selectedItems.filter((item) => !item?.is_ready_to_publish)
|
||||
const reviewBlockedCount = blockedItems.filter((item) => item?.status === 'needs_review').length
|
||||
const metadataBlockedCount = blockedItems.filter((item) => item?.status === 'needs_metadata').length
|
||||
const processingBlockedCount = blockedItems.filter((item) => item?.status === 'processing').length
|
||||
const failedBlockedCount = blockedItems.filter((item) => item?.status === 'failed').length
|
||||
|
||||
return {
|
||||
totalCount: selectedItems.length,
|
||||
readyCount: readyItems.length,
|
||||
blockedCount: blockedItems.length,
|
||||
reviewBlockedCount,
|
||||
metadataBlockedCount,
|
||||
processingBlockedCount,
|
||||
failedBlockedCount,
|
||||
}
|
||||
}
|
||||
|
||||
const confirmPublishSelection = (ids) => {
|
||||
const summary = summarizePublishSelection(ids)
|
||||
|
||||
if (summary.totalCount === 0) {
|
||||
pushNotice('warning', 'Select at least one queue item first.')
|
||||
return false
|
||||
}
|
||||
|
||||
if (summary.readyCount === 0) {
|
||||
pushNotice('warning', 'None of the selected drafts are ready to publish yet.')
|
||||
return false
|
||||
}
|
||||
|
||||
const message = [
|
||||
`Publish ${summary.readyCount} ready draft(s)?`,
|
||||
`Selected: ${summary.totalCount}`,
|
||||
`Ready now: ${summary.readyCount}`,
|
||||
`Blocked and skipped: ${summary.blockedCount}`,
|
||||
]
|
||||
|
||||
if (summary.reviewBlockedCount > 0) {
|
||||
message.push(`Needs review: ${summary.reviewBlockedCount}`)
|
||||
}
|
||||
if (summary.metadataBlockedCount > 0) {
|
||||
message.push(`Missing metadata: ${summary.metadataBlockedCount}`)
|
||||
}
|
||||
if (summary.processingBlockedCount > 0) {
|
||||
message.push(`Still processing: ${summary.processingBlockedCount}`)
|
||||
}
|
||||
if (summary.failedBlockedCount > 0) {
|
||||
message.push(`Failed items: ${summary.failedBlockedCount}`)
|
||||
}
|
||||
|
||||
message.push('Blocked drafts will not be published.')
|
||||
|
||||
return window.confirm(message.join('\n'))
|
||||
}
|
||||
|
||||
const runBulkAction = async (action, params = {}, ids = selectedIds) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
pushNotice('warning', 'Select at least one queue item first.')
|
||||
return
|
||||
}
|
||||
|
||||
let confirmValue = undefined
|
||||
if (action === 'publish') {
|
||||
if (!confirmPublishSelection(ids)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const value = window.prompt('Type DELETE to remove the selected drafts from the queue.')
|
||||
if (value !== 'DELETE') {
|
||||
return
|
||||
}
|
||||
confirmValue = value
|
||||
}
|
||||
|
||||
setBusyAction(action)
|
||||
try {
|
||||
const response = await window.axios.post('/api/studio/upload-queue/bulk', {
|
||||
action,
|
||||
item_ids: ids,
|
||||
params,
|
||||
confirm: confirmValue,
|
||||
})
|
||||
|
||||
const success = Number(response?.data?.success || 0)
|
||||
const failed = Number(response?.data?.failed || 0)
|
||||
if (failed > 0 && success === 0) {
|
||||
pushNotice('error', response?.data?.errors?.[0] || 'The queue action failed.')
|
||||
} else if (failed > 0) {
|
||||
pushNotice('warning', `${success} item(s) updated. ${failed} item(s) could not be changed.`)
|
||||
} else {
|
||||
pushNotice('success', `${success} item(s) updated.`)
|
||||
}
|
||||
|
||||
await loadQueue({ batch_id: selectedBatchId })
|
||||
setSelectedIds([])
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.errors?.[0]
|
||||
|| error?.response?.data?.message
|
||||
|| 'The queue action failed.'
|
||||
pushNotice('error', message)
|
||||
} finally {
|
||||
setBusyAction('')
|
||||
}
|
||||
}
|
||||
|
||||
const retryItem = async (itemId) => {
|
||||
setBusyAction(`retry-${itemId}`)
|
||||
try {
|
||||
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/retry`)
|
||||
pushNotice('success', 'Background processing has been queued again for this draft.')
|
||||
await loadQueue({ batch_id: selectedBatchId })
|
||||
} catch (error) {
|
||||
pushNotice('error', error?.response?.data?.message || 'Retry failed for this queue item.')
|
||||
} finally {
|
||||
setBusyAction('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchChange = async (event) => {
|
||||
const nextBatchId = event.target.value
|
||||
setSelectedBatchId(nextBatchId)
|
||||
await loadQueue({ batch_id: nextBatchId || undefined })
|
||||
}
|
||||
|
||||
const onDropFiles = (event) => {
|
||||
event.preventDefault()
|
||||
const dropped = Array.from(event.dataTransfer.files || [])
|
||||
setFiles(dropped)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-6">
|
||||
{notice && (
|
||||
<div className={`rounded-[24px] border px-4 py-3 text-sm ${noticeClasses(notice.type)}`}>
|
||||
{notice.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.96))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.3)]">
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Bulk upload drafts</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Start a batch, then let Studio handle the review queue.</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">
|
||||
Each file becomes a normal draft artwork. Upload transport happens now, thumbnail and maturity work continue in the background, and publishing stays blocked until the draft is actually ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:w-[380px]">
|
||||
<SummaryCard label="Selected files" value={files.length} hint="Add multiple images to build a batch." />
|
||||
<SummaryCard label="Current batch" value={currentBatch?.total_items || 0} hint={currentBatch ? `Status: ${String(currentBatch.status || 'uploading').replace(/_/g, ' ')}` : 'No active batch selected.'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr,0.8fr]">
|
||||
<div
|
||||
className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.03] p-5 transition hover:border-sky-300/35"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDropFiles}
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Drag multiple image files here</p>
|
||||
<p className="mt-2 text-sm text-slate-400">PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15"
|
||||
>
|
||||
<i className="fa-solid fa-cloud-arrow-up" />
|
||||
Choose files
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => setFiles(Array.from(event.target.files || []))}
|
||||
/>
|
||||
<span className="text-sm text-slate-500">{files.length > 0 ? `${files.length} file(s) ready` : 'Nothing selected yet'}</span>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch contents</p>
|
||||
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1 text-sm text-slate-300">
|
||||
{files.map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-3 rounded-2xl border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="text-xs text-slate-500">{Math.max(1, Math.round(file.size / 1024))} KB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-sm font-semibold text-white">Shared defaults</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch name</span>
|
||||
<input
|
||||
value={defaults.name}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, name: event.target.value }))}
|
||||
className="mt-2 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/35"
|
||||
placeholder="Optional batch label"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Category</span>
|
||||
<select
|
||||
value={defaults.categoryId}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, categoryId: event.target.value }))}
|
||||
className="mt-2 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/35"
|
||||
>
|
||||
<option value="">No shared category</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visibility when published</span>
|
||||
<select
|
||||
value={defaults.visibility}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, visibility: event.target.value }))}
|
||||
className="mt-2 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/35"
|
||||
>
|
||||
<option value="public" className="bg-slate-950">Public</option>
|
||||
<option value="unlisted" className="bg-slate-950">Unlisted</option>
|
||||
<option value="private" className="bg-slate-950">Private</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shared tags</span>
|
||||
<textarea
|
||||
value={defaults.tags}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, tags: event.target.value }))}
|
||||
className="mt-2 min-h-[92px] 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/35"
|
||||
placeholder="fantasy, portrait, wallpaper"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaults.isMature}
|
||||
onChange={(event) => setDefaults((current) => ({ ...current, isMature: event.target.checked }))}
|
||||
className="h-4 w-4 rounded border-white/20 bg-transparent"
|
||||
/>
|
||||
Mark all files as creator-declared mature
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startUpload}
|
||||
disabled={uploading || files.length === 0}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/45 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-play" />
|
||||
{busyAction === 'create-batch' ? 'Creating batch...' : 'Start upload batch'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Queue view</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Review a batch, then work the drafts that actually need attention.</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch</span>
|
||||
<select
|
||||
value={selectedBatchId}
|
||||
onChange={handleBatchChange}
|
||||
className="mt-2 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/35"
|
||||
>
|
||||
<option value="">Latest batch</option>
|
||||
{batches.map((batch) => (
|
||||
<option key={batch.id} value={batch.id} className="bg-slate-950">
|
||||
{batch.name || `Batch #${batch.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Filter</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={async (event) => {
|
||||
const nextStatus = event.target.value
|
||||
setStatusFilter(nextStatus)
|
||||
await loadQueue({ status: nextStatus })
|
||||
}}
|
||||
className="mt-2 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/35"
|
||||
>
|
||||
{(queue?.status_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sort</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={async (event) => {
|
||||
const nextSort = event.target.value
|
||||
setSort(nextSort)
|
||||
await loadQueue({ sort: nextSort })
|
||||
}}
|
||||
className="mt-2 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/35"
|
||||
>
|
||||
{(queue?.sort_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentBatch && (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-5">
|
||||
<SummaryCard label="Batch status" value={String(currentBatch.status || 'uploading').replace(/_/g, ' ')} hint={`Updated ${formatDate(currentBatch.updated_at)}`} />
|
||||
<SummaryCard label="Ready" value={currentBatch.ready_items || 0} hint="Can be published right now." />
|
||||
<SummaryCard label="Processing" value={currentBatch.processing_items || 0} hint="Still moving through the pipeline." />
|
||||
<SummaryCard label="Needs review" value={currentBatch.needs_review_items || 0} hint="Blocked on maturity or review." />
|
||||
<SummaryCard label="Failed" value={currentBatch.failed_items || 0} hint="Needs retry or a fresh upload." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-[28px] border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
disabled={selectableIds.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-check-double" />
|
||||
{allSelected ? 'Clear selection' : 'Select visible'}
|
||||
</button>
|
||||
<span className="text-sm text-slate-500">{selectedIds.length} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('publish')}
|
||||
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Publish selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('generate_ai')}
|
||||
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Generate AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('delete')}
|
||||
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr,1fr,auto,auto]">
|
||||
<select
|
||||
value={bulkForm.categoryId}
|
||||
onChange={(event) => setBulkForm((current) => ({ ...current, categoryId: event.target.value }))}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="">Apply category...</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={bulkForm.tags}
|
||||
onChange={(event) => setBulkForm((current) => ({ ...current, tags: event.target.value }))}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||
placeholder="Add shared tags to selection"
|
||||
/>
|
||||
<select
|
||||
value={bulkForm.visibility}
|
||||
onChange={(event) => setBulkForm((current) => ({ ...current, visibility: event.target.value }))}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="public" className="bg-slate-950">Public</option>
|
||||
<option value="unlisted" className="bg-slate-950">Unlisted</option>
|
||||
<option value="private" className="bg-slate-950">Private</option>
|
||||
</select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('apply_category', { category_id: Number(bulkForm.categoryId) })}
|
||||
disabled={busyAction !== '' || selectedIds.length === 0 || !bulkForm.categoryId}
|
||||
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Apply category
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('apply_tags', { tags: parseTags(bulkForm.tags) })}
|
||||
disabled={busyAction !== '' || selectedIds.length === 0 || parseTags(bulkForm.tags).length === 0}
|
||||
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Apply tags
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('set_visibility', { visibility: bulkForm.visibility })}
|
||||
disabled={busyAction !== '' || selectedIds.length === 0}
|
||||
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Set visibility
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.02] p-8 text-center text-sm text-slate-400">
|
||||
No items match this view yet. Start a batch above or switch to another recent batch.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const localUpload = uploadState[item.id] || null
|
||||
const progress = localUpload?.progress ?? null
|
||||
const actionState = item.actions || {}
|
||||
|
||||
return (
|
||||
<article key={item.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="mt-1 inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(Number(item.id))}
|
||||
onChange={() => handleToggleSelected(Number(item.id))}
|
||||
className="h-4 w-4 rounded border-white/20 bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="h-24 w-24 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
|
||||
{item.thumbnail_url ? (
|
||||
<img src={item.thumbnail_url} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-image text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<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-semibold uppercase tracking-[0.16em] ${statusClasses(item.status)}`}>
|
||||
{String(item.status || 'processing').replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${batchStatusClasses(item.processing_stage === 'finalized' ? 'completed' : 'processing')}`}>
|
||||
{humanStage(item.processing_stage)}
|
||||
</span>
|
||||
{item.is_ready_to_publish && (
|
||||
<span className="inline-flex items-center rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">
|
||||
Ready to publish
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 truncate text-sm text-slate-400">{item.original_filename}</p>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</span>
|
||||
<p className="mt-2 text-white">{item.metadata_label}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Updated</span>
|
||||
<p className="mt-2 text-white">{formatDate(item.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof progress === 'number' && progress < 100 && (
|
||||
<div className="mt-4 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-3 py-3 text-sm text-sky-100">
|
||||
Uploading now: {formatPercent(progress)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(item.missing) && item.missing.length > 0 && (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-3 text-sm text-slate-300">
|
||||
{item.missing.join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.error_message && (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-3 py-3 text-sm text-rose-100">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{actionState.can_edit && item.edit_url && (
|
||||
<a href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||
<i className="fa-solid fa-pen-to-square" />
|
||||
Edit in Studio
|
||||
</a>
|
||||
)}
|
||||
{actionState.can_publish && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('publish', {}, [item.id])}
|
||||
disabled={busyAction !== ''}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-rocket" />
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{actionState.can_generate_ai && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('generate_ai', {}, [item.id])}
|
||||
disabled={busyAction !== ''}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
Generate AI
|
||||
</button>
|
||||
)}
|
||||
{actionState.can_retry_processing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryItem(item.id)}
|
||||
disabled={busyAction !== ''}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-sm font-semibold text-amber-100 transition hover:border-amber-400/40 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-right" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{actionState.can_delete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runBulkAction('delete', {}, [item.id])}
|
||||
disabled={busyAction !== ''}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-trash" />
|
||||
Delete draft
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
|
||||
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
|
||||
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileCoverEditor from './ProfileCoverEditor'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import XPProgressBar from '../xp/XPProgressBar'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
import FollowersPreview from '../social/FollowersPreview'
|
||||
import MutualFollowersBadge from '../social/MutualFollowersBadge'
|
||||
import { shinyFlagUrl } from '../../utils/flagUrl'
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
const numeric = Number(value ?? 0)
|
||||
@@ -12,11 +14,13 @@ function formatCompactNumber(value) {
|
||||
}
|
||||
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||
const { props } = usePage()
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
const [count, setCount] = useState(followerCount)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
||||
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
||||
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
|
||||
|
||||
const uname = user.username || user.name || 'Unknown'
|
||||
const displayName = user.name || uname
|
||||
@@ -118,9 +122,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
||||
{profile?.country_code ? (
|
||||
{flagUrl ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
src={flagUrl}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(event) => { event.target.style.display = 'none' }}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import { shinyFlagUrl } from '../../../utils/flagUrl'
|
||||
|
||||
const SOCIAL_ICONS = {
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
|
||||
@@ -170,10 +172,12 @@ function SectionCard({ icon, eyebrow, title, children, className = '' }) {
|
||||
* Bio, social links, metadata - replaces old sidebar profile card.
|
||||
*/
|
||||
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory }) {
|
||||
const { props } = usePage()
|
||||
const uname = user.username || user.name
|
||||
const displayName = user.name || uname
|
||||
const about = profile?.about
|
||||
const website = profile?.website
|
||||
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
|
||||
|
||||
const joinDate = user.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
@@ -250,9 +254,9 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
{countryName ? (
|
||||
<InfoRow icon="fa-earth-americas" label="Country">
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.country_code ? (
|
||||
{flagUrl ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
src={flagUrl}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
|
||||
@@ -13,6 +13,7 @@ function mount() {
|
||||
username: container.dataset.username || '',
|
||||
avatarUrl: container.dataset.avatarUrl || null,
|
||||
uploadUrl: container.dataset.uploadUrl || '/upload',
|
||||
moderationUrl: container.dataset.moderationUrl || null,
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function shinyFlagUrl(countryCode, filesCdnUrl = '') {
|
||||
const normalized = String(countryCode ?? '').trim().toUpperCase()
|
||||
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = String(filesCdnUrl ?? '').replace(/\/+$/, '')
|
||||
const relativePath = `/images/flags/shiny/24/${encodeURIComponent(normalized)}.png`
|
||||
|
||||
return base ? `${base}${relativePath}` : relativePath
|
||||
}
|
||||
@@ -192,7 +192,7 @@
|
||||
@if($countryName)
|
||||
<p class="text-[--sb-muted] text-sm mt-1 flex items-center justify-center sm:justify-start gap-1.5">
|
||||
@if($profile?->country_code)
|
||||
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
|
||||
<img src="{{ rtrim((string) config('cdn.files_url', ''), '/') }}/images/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
|
||||
alt="{{ e($countryName) }}"
|
||||
class="w-5 h-auto rounded-sm inline-block"
|
||||
onerror="this.style.display='none'">
|
||||
@@ -434,7 +434,7 @@
|
||||
<td>Country</td>
|
||||
<td class="flex items-center justify-end gap-1.5">
|
||||
@if($profile?->country_code)
|
||||
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
|
||||
<img src="{{ rtrim((string) config('cdn.files_url', ''), '/') }}/images/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
|
||||
alt="{{ e($countryName) }}"
|
||||
class="w-4 h-auto rounded-sm"
|
||||
onerror="this.style.display='none'">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
$comments = $comments ?? [];
|
||||
$groupSummary = $groupSummary ?? null;
|
||||
$useUnifiedSeo = true;
|
||||
$canReadSessionAuth = request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped');
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
@@ -49,7 +50,7 @@
|
||||
data-canonical='@json($meta["canonical"])'
|
||||
data-comments='@json($comments)'
|
||||
data-group-summary='@json($groupSummary)'
|
||||
data-is-authenticated='@json(auth()->check())'>
|
||||
data-is-authenticated='@json($canReadSessionAuth && auth()->check())'>
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/Pages/ArtworkPage.jsx'])
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@vite(['resources/js/collections.jsx'])
|
||||
<style>
|
||||
body.page-collections main { padding-top: 4rem; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@php
|
||||
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
|
||||
$skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true;
|
||||
$skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped;
|
||||
$deferToolbarSearch = request()->routeIs('index');
|
||||
$deferFontAwesome = request()->routeIs('index');
|
||||
$deferWebManifest = request()->routeIs('index');
|
||||
@@ -21,7 +23,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@if($skinbaseCanUseSession)
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@endif
|
||||
@if($shouldRenderBladeSeo)
|
||||
@include('partials.seo.head', ['seo' => $seo ?? null])
|
||||
@endif
|
||||
@@ -235,13 +239,14 @@
|
||||
|
||||
<!-- React Topbar mount point -->
|
||||
<div id="topbar-root"
|
||||
@auth
|
||||
@if($skinbaseCanUseSession && Auth::check())
|
||||
data-user-id="{{ Auth::id() }}"
|
||||
data-display-name="{{ Auth::user()->name ?? '' }}"
|
||||
data-username="{{ Auth::user()->username ?? '' }}"
|
||||
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
|
||||
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
|
||||
@endauth
|
||||
data-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
|
||||
@endif
|
||||
></div>
|
||||
@include('layouts.nova.toolbar')
|
||||
<main class="flex-1 @yield('main-class', 'pt-16')">
|
||||
@@ -252,9 +257,9 @@
|
||||
|
||||
{{-- Toast notifications (Alpine) --}}
|
||||
@php
|
||||
$toastMessage = session('status') ?? session('error') ?? null;
|
||||
$toastType = session('error') ? 'error' : 'success';
|
||||
$toastBorder = session('error') ? 'border-red-500' : 'border-green-500';
|
||||
$toastMessage = $skinbaseCanUseSession ? (session('status') ?? session('error') ?? null) : null;
|
||||
$toastType = $skinbaseCanUseSession && session('error') ? 'error' : 'success';
|
||||
$toastBorder = $skinbaseCanUseSession && session('error') ? 'border-red-500' : 'border-green-500';
|
||||
@endphp
|
||||
@if($toastMessage)
|
||||
<div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak
|
||||
@@ -262,7 +267,7 @@
|
||||
<div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nova-600 border {{ $toastBorder }}">
|
||||
<div class="px-4 py-3 flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
@if(session('error'))
|
||||
@if($skinbaseCanUseSession && session('error'))
|
||||
<svg class="w-6 h-6 text-red-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/></svg>
|
||||
@else
|
||||
<svg class="w-6 h-6 text-green-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@php
|
||||
$skinbaseCanUseSession = ($skinbaseCanUseSession ?? false) === true;
|
||||
$skinbaseToolbarUser = $skinbaseCanUseSession ? Auth::user() : null;
|
||||
$skinbaseToolbarCanAuth = $skinbaseToolbarUser !== null;
|
||||
@endphp
|
||||
|
||||
<header id="nova-toolbar" class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
|
||||
<div class="mx-auto w-full h-full px-3 sm:px-4 flex items-center gap-2 sm:gap-3">
|
||||
|
||||
@@ -19,7 +25,7 @@
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 pr-2 shrink-0">
|
||||
<img src="/gfx/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
|
||||
<img src="https://cdn.skinbase.org/images/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
|
||||
<span class="sr-only">Skinbase.org</span>
|
||||
</a>
|
||||
|
||||
@@ -80,11 +86,11 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
|
||||
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
|
||||
</a>
|
||||
@auth
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('discover.for-you') }}">
|
||||
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You
|
||||
</a>
|
||||
@endauth
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,11 +171,11 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/stories">
|
||||
<i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories
|
||||
</a>
|
||||
@auth
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.following') }}">
|
||||
<i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following
|
||||
</a>
|
||||
@endauth
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,7 +191,7 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('community.activity') }}">
|
||||
<i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed
|
||||
</a>
|
||||
@auth
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
|
||||
<span class="flex items-center gap-3">
|
||||
<i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments
|
||||
@@ -194,7 +200,7 @@
|
||||
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endauth
|
||||
@endif
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
|
||||
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
|
||||
</a>
|
||||
@@ -241,7 +247,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@auth
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<!-- Notification icons -->
|
||||
<div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0">
|
||||
<a href="{{ route('dashboard.favorites') }}"
|
||||
@@ -304,6 +310,7 @@
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
|
||||
$routeModeration = '/moderation';
|
||||
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
|
||||
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
|
||||
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
|
||||
@@ -366,26 +373,24 @@
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
|
||||
Upload
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/studio/artworks">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
|
||||
Studio
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeMyStories }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-book-open text-xs text-sb-muted"></i></span>
|
||||
My Stories
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeModeration }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
|
||||
Moderation
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-heart text-xs text-sb-muted"></i></span>
|
||||
My Favorites
|
||||
@endif
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
|
||||
<span class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-inbox text-xs text-sb-muted"></i></span>
|
||||
<span>Received Comments</span>
|
||||
<span>Comments</span>
|
||||
</span>
|
||||
@if(($receivedCommentsCount ?? 0) > 0)
|
||||
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
|
||||
@@ -403,13 +408,6 @@
|
||||
Settings
|
||||
</a>
|
||||
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
|
||||
Moderation
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="border-t border-panel mt-1 mb-1"></div>
|
||||
<form method="POST" action="{{ route('logout') }}" class="mb-1">
|
||||
@csrf
|
||||
@@ -458,7 +456,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
@endauth
|
||||
@endif
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -466,9 +464,9 @@
|
||||
<div class="hidden fixed inset-x-0 top-16 bottom-0 z-40 overflow-y-auto overscroll-contain bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu">
|
||||
<div class="space-y-0.5 text-sm text-soft">
|
||||
|
||||
@guest
|
||||
@if(! $skinbaseToolbarCanAuth)
|
||||
<div class="my-2 border-t border-panel"></div>
|
||||
@endguest
|
||||
@endif
|
||||
|
||||
<div class="pt-1">
|
||||
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionDiscover" aria-expanded="true" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
|
||||
@@ -483,16 +481,19 @@
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('downloads.today') }}"><i class="fa-solid fa-arrow-down-short-wide w-4 text-center text-sb-muted"></i>Today Downloads</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionBrowse" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
|
||||
<span>Browse</span>
|
||||
<span>Explore</span>
|
||||
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
|
||||
</button>
|
||||
<div id="mobileSectionBrowse" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/explore"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
|
||||
@foreach($toolbarContentTypes as $contentType)
|
||||
@php
|
||||
$contentTypeSlug = strtolower((string) $contentType->slug);
|
||||
@@ -500,6 +501,7 @@
|
||||
@endphp
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/{{ $contentTypeSlug }}"><i class="fa-solid {{ $contentTypeIcon }} w-4 text-center text-sb-muted"></i>{{ $contentType->name }}</a>
|
||||
@endforeach
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('categories.index') }}"><i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Categories</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,10 +530,9 @@
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/leaderboard"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Leaderboard</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
|
||||
@auth
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('creator.stories.index') }}"><i class="fa-solid fa-rectangle-list w-4 text-center text-sb-muted"></i>My Stories</a>
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a>
|
||||
@endauth
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -542,9 +543,9 @@
|
||||
</button>
|
||||
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('community.activity') }}"><i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed</a>
|
||||
@auth
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<a class="flex items-center justify-between gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"><span class="flex items-center gap-3"><i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments</span>@if(($receivedCommentsCount ?? 0) > 0)<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>@endif</a>
|
||||
@endauth
|
||||
@endif
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News</a>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
@section('title', 'Messages')
|
||||
|
||||
@push('head')
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@vite(['resources/js/moderation.jsx'])
|
||||
<style>
|
||||
body.page-moderation main { padding-top: 4rem; }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@vite(['resources/js/settings.jsx'])
|
||||
<style>
|
||||
body.page-settings main { padding-top: 4rem; }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@vite(['resources/js/studio.jsx'])
|
||||
<style>
|
||||
body.page-studio main { padding-top: 2.3rem; }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
|
||||
<script>
|
||||
window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
$hero_description = "We're always grateful for volunteers who want to help.";
|
||||
$center_content = true;
|
||||
$center_max = '3xl';
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
|
||||
@@ -28,6 +28,7 @@ Route::middleware(['web', 'throttle:60,1'])->prefix('leaderboard')->name('api.le
|
||||
Route::get('artworks', [\App\Http\Controllers\Api\LeaderboardController::class, 'artworks'])->name('artworks');
|
||||
Route::get('groups', [\App\Http\Controllers\Api\LeaderboardController::class, 'groups'])->name('groups');
|
||||
Route::get('stories', [\App\Http\Controllers\Api\LeaderboardController::class, 'stories'])->name('stories');
|
||||
Route::get('worlds', [\App\Http\Controllers\Api\LeaderboardController::class, 'worlds'])->name('worlds');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
|
||||
@@ -49,7 +50,7 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover'
|
||||
|
||||
// ── Per-artwork signal tracking (public) ────────────────────────────────────
|
||||
// GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch)
|
||||
// POST /api/art/{id}/view → record a view (session-deduped, 5 per 10 min)
|
||||
// POST /api/art/{id}/view → record a page visit as a view
|
||||
// POST /api/art/{id}/download → record a download, returns file URL (10/min)
|
||||
Route::middleware(['web', 'throttle:300,1'])
|
||||
->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class)
|
||||
@@ -61,7 +62,7 @@ Route::middleware(['web', 'throttle:120,1'])
|
||||
->whereNumber('id')
|
||||
->name('api.art.similar-ai');
|
||||
|
||||
Route::middleware(['web', 'throttle:5,10'])
|
||||
Route::middleware(['web', 'throttle:120,1'])
|
||||
->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class)
|
||||
->middleware('forum.bot.protection:api_write')
|
||||
->whereNumber('id')
|
||||
@@ -86,6 +87,27 @@ Route::middleware(['web', 'throttle:social-read'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('api.profile.activity');
|
||||
|
||||
Route::middleware(['web', 'throttle:social-read'])
|
||||
->get('profile/{username}/journey', \App\Http\Controllers\Api\ProfileJourneyController::class)
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('api.profile.journey');
|
||||
|
||||
// Public profile AI biography (read, included in profile payload via journey endpoint)
|
||||
Route::middleware(['web', 'throttle:social-read'])
|
||||
->get('profile/{username}/ai-biography', [\App\Http\Controllers\Api\ProfileAiBiographyController::class, 'show'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('api.profile.ai-biography');
|
||||
|
||||
// Creator-facing AI biography management (authenticated)
|
||||
Route::middleware(['web', 'auth'])->prefix('creator/profile/ai-biography')->name('api.creator.ai-biography.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\AiBiographyController::class, 'status'])->name('status');
|
||||
Route::post('/generate', [\App\Http\Controllers\Api\AiBiographyController::class, 'generate'])->name('generate')->middleware('throttle:5,1');
|
||||
Route::post('/regenerate', [\App\Http\Controllers\Api\AiBiographyController::class, 'regenerate'])->name('regenerate')->middleware('throttle:5,1');
|
||||
Route::patch('/', [\App\Http\Controllers\Api\AiBiographyController::class, 'update'])->name('update')->middleware('throttle:20,1');
|
||||
Route::post('/hide', [\App\Http\Controllers\Api\AiBiographyController::class, 'hide'])->name('hide');
|
||||
Route::post('/show', [\App\Http\Controllers\Api\AiBiographyController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'throttle:social-read'])
|
||||
->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments'])
|
||||
->name('api.social.comments.index');
|
||||
@@ -117,6 +139,13 @@ Route::prefix('rank')->name('api.rank.')->middleware(['throttle:60,1'])->group(f
|
||||
// ── Studio Pro API (authenticated) ─────────────────────────────────────────────
|
||||
Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group(function () {
|
||||
Route::post('events', [\App\Http\Controllers\Studio\StudioEventsApiController::class, 'store'])->name('events.store');
|
||||
Route::get('upload-queue', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'index'])->name('upload-queue.index');
|
||||
Route::post('upload-queue/batches', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'store'])->name('upload-queue.store');
|
||||
Route::post('upload-queue/bulk', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'bulk'])->name('upload-queue.bulk');
|
||||
Route::post('upload-queue/items/{id}/fail', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'markFailed'])->whereNumber('id')->name('upload-queue.items.fail');
|
||||
Route::post('upload-queue/items/{id}/retry', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'retry'])->whereNumber('id')->name('upload-queue.items.retry');
|
||||
Route::post('worlds/media/upload', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.upload');
|
||||
Route::delete('worlds/media', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.destroy');
|
||||
Route::put('preferences', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updatePreferences'])->name('preferences.settings');
|
||||
Route::put('preferences/profile', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updateProfile'])->name('preferences.profile');
|
||||
Route::put('preferences/featured', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updateFeatured'])->name('preferences.featured');
|
||||
@@ -138,6 +167,7 @@ Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group
|
||||
Route::post('artworks/{id}/ai/events', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'event'])->whereNumber('id')->name('artworks.ai.events');
|
||||
Route::post('artworks/{id}/ai/regenerate', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'regenerate'])->whereNumber('id')->name('artworks.ai.regenerate');
|
||||
Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile');
|
||||
Route::post('artworks/{id}/revise-media', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'reviseMedia'])->whereNumber('id')->name('artworks.reviseMedia');
|
||||
// Versioning
|
||||
Route::get('artworks/{id}/versions', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'versions'])->whereNumber('id')->name('artworks.versions');
|
||||
Route::post('artworks/{id}/restore/{version_id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'restoreVersion'])->whereNumber('id')->whereNumber('version_id')->name('artworks.restoreVersion');
|
||||
|
||||
@@ -5,6 +5,7 @@ use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\User\ProfileController;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Group;
|
||||
use App\Models\World;
|
||||
use App\Http\Controllers\User\AvatarController;
|
||||
use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController;
|
||||
use App\Http\Controllers\Web\ArtworkPageController;
|
||||
@@ -20,6 +21,7 @@ use App\Http\Controllers\StoryController;
|
||||
use App\Http\Controllers\Web\HomeController;
|
||||
use App\Http\Controllers\Web\FooterController;
|
||||
use App\Http\Controllers\Web\BugReportController;
|
||||
use App\Http\Controllers\Web\WorldController;
|
||||
use App\Http\Controllers\RobotsTxtController;
|
||||
use App\Http\Controllers\SitemapController;
|
||||
use App\Http\Controllers\Web\StaffController;
|
||||
@@ -36,6 +38,7 @@ use App\Http\Controllers\RSS\CreatorFeedController;
|
||||
use App\Http\Controllers\RSS\BlogFeedController;
|
||||
use App\Http\Controllers\Studio\StudioNewsController;
|
||||
use App\Http\Controllers\Studio\StudioController;
|
||||
use App\Http\Controllers\Studio\StudioWorldController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\Community\LatestController;
|
||||
use App\Http\Controllers\User\MembersController;
|
||||
@@ -113,6 +116,7 @@ Route::get('/help', \App\Http\Controllers\Web\HelpCenterPageController::class)->
|
||||
Route::get('/help/studio', \App\Http\Controllers\Web\StudioHelpPageController::class)->name('help.studio');
|
||||
Route::get('/help/upload', \App\Http\Controllers\Web\UploadHelpPageController::class)->name('help.upload');
|
||||
Route::get('/help/cards', \App\Http\Controllers\Web\CardsHelpPageController::class)->name('help.cards');
|
||||
Route::get('/help/worlds', \App\Http\Controllers\Web\WorldsHelpPageController::class)->name('help.worlds');
|
||||
Route::get('/help/profile', \App\Http\Controllers\Web\ProfileHelpPageController::class)->name('help.profile');
|
||||
Route::get('/help/auth', \App\Http\Controllers\Web\AuthHelpPageController::class)->name('help.auth');
|
||||
Route::get('/help/account', \App\Http\Controllers\Web\AccountHelpPageController::class)->name('help.account');
|
||||
@@ -278,6 +282,24 @@ Route::get('/collections/featured', [\App\Http\Controllers\Web\CollectionDiscove
|
||||
->name('collections.featured');
|
||||
Route::get('/collections/trending', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'trending'])
|
||||
->name('collections.trending');
|
||||
Route::get('/worlds', [WorldController::class, 'index'])->name('worlds.index');
|
||||
Route::get('/worlds/create', function (Request $request) {
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return redirect()->guest(route('login'));
|
||||
}
|
||||
|
||||
if ($user->can('create', World::class)) {
|
||||
return redirect()->route('studio.worlds.create', $request->query(), 302);
|
||||
}
|
||||
|
||||
return redirect()->route('worlds.index', $request->query(), 302);
|
||||
})
|
||||
->name('worlds.create.redirect');
|
||||
Route::get('/worlds/{world:slug}', [WorldController::class, 'show'])
|
||||
->where('world', '^(?!create$)[a-z0-9]+(?:-[a-z0-9]+)*$')
|
||||
->name('worlds.show');
|
||||
Route::get('/collections/editorial', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'editorial'])
|
||||
->name('collections.editorial');
|
||||
Route::get('/collections/community', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'community'])
|
||||
@@ -426,6 +448,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
|
||||
Route::get('/content', [StudioController::class, 'content'])->name('content');
|
||||
Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks');
|
||||
Route::get('/drafts', [StudioController::class, 'drafts'])->name('drafts');
|
||||
Route::get('/upload-queue', [StudioController::class, 'uploadQueue'])->name('upload-queue');
|
||||
Route::get('/scheduled', [StudioController::class, 'scheduled'])->name('scheduled');
|
||||
Route::get('/calendar', [StudioController::class, 'calendar'])->name('calendar');
|
||||
Route::get('/archived', [StudioController::class, 'archived'])->name('archived');
|
||||
@@ -471,6 +494,25 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
|
||||
Route::post('/news/{article}/archive', [StudioNewsController::class, 'archive'])->whereNumber('article')->name('news.archive');
|
||||
Route::post('/news/{article}/feature', [StudioNewsController::class, 'feature'])->whereNumber('article')->name('news.feature');
|
||||
Route::post('/news/{article}/pin', [StudioNewsController::class, 'pin'])->whereNumber('article')->name('news.pin');
|
||||
Route::get('/worlds', [StudioWorldController::class, 'index'])->name('worlds.index');
|
||||
Route::get('/worlds/create', [StudioWorldController::class, 'create'])->name('worlds.create');
|
||||
Route::post('/worlds', [StudioWorldController::class, 'store'])->name('worlds.store');
|
||||
Route::get('/worlds/entity-search', [StudioWorldController::class, 'entitySearch'])->name('worlds.entity-search');
|
||||
Route::get('/worlds/{world}/preview', [StudioWorldController::class, 'preview'])->whereNumber('world')->name('worlds.preview');
|
||||
Route::get('/worlds/{world}/edit', [StudioWorldController::class, 'edit'])->whereNumber('world')->name('worlds.edit');
|
||||
Route::patch('/worlds/{world}', [StudioWorldController::class, 'update'])->whereNumber('world')->name('worlds.update');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/approve', [StudioWorldController::class, 'approveSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.approve');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/remove', [StudioWorldController::class, 'removeSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.remove');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/block', [StudioWorldController::class, 'blockSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.block');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/unblock', [StudioWorldController::class, 'unblockSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.unblock');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/restore', [StudioWorldController::class, 'restoreSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.restore');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/feature', [StudioWorldController::class, 'featureSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.feature');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/unfeature', [StudioWorldController::class, 'unfeatureSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.unfeature');
|
||||
Route::post('/worlds/{world}/submissions/{submission}/pending', [StudioWorldController::class, 'pendingSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.pending');
|
||||
Route::post('/worlds/{world}/publish', [StudioWorldController::class, 'publish'])->whereNumber('world')->name('worlds.publish');
|
||||
Route::post('/worlds/{world}/archive', [StudioWorldController::class, 'archive'])->whereNumber('world')->name('worlds.archive');
|
||||
Route::post('/worlds/{world}/duplicate', [StudioWorldController::class, 'duplicate'])->whereNumber('world')->name('worlds.duplicate');
|
||||
Route::post('/worlds/{world}/new-edition', [StudioWorldController::class, 'newEdition'])->whereNumber('world')->name('worlds.new-edition');
|
||||
Route::get('/groups', [\App\Http\Controllers\Studio\GroupStudioController::class, 'index'])->name('groups.index');
|
||||
Route::get('/groups/create', [\App\Http\Controllers\Studio\GroupStudioController::class, 'create'])->name('groups.create');
|
||||
Route::post('/groups', [\App\Http\Controllers\Studio\GroupStudioController::class, 'store'])->name('groups.store');
|
||||
@@ -629,6 +671,15 @@ Route::middleware(['artwork.maturity.access'])->prefix('cp/maturity')->name('cp.
|
||||
Route::post('/{artwork:id}/review', [\App\Http\Controllers\Settings\ArtworkMaturityAdminController::class, 'review'])->whereNumber('artwork')->name('review');
|
||||
});
|
||||
|
||||
Route::middleware(['artwork.maturity.access'])->prefix('cp/ai-biography')->name('cp.ai-biography.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'index'])->name('index');
|
||||
Route::post('/users/{user}/rebuild', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'rebuild'])->whereNumber('user')->name('rebuild');
|
||||
Route::post('/records/{biography}/approve', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'approve'])->whereNumber('biography')->name('approve');
|
||||
Route::post('/records/{biography}/flag', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'flag'])->whereNumber('biography')->name('flag');
|
||||
Route::post('/records/{biography}/hide', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'hide'])->whereNumber('biography')->name('hide');
|
||||
Route::post('/records/{biography}/show', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'show'])->whereNumber('biography')->name('show');
|
||||
});
|
||||
|
||||
// ── SETTINGS / PROFILE EDIT ───────────────────────────────────────────────────
|
||||
Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () {
|
||||
Route::get('/profile', fn () => redirect()->route('dashboard.profile', [], 301))->name('legacy.profile.redirect');
|
||||
@@ -779,6 +830,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
|
||||
'draftId' => null,
|
||||
'content_types' => $contentTypes,
|
||||
'suggested_tags' => [],
|
||||
'eligible_worlds' => app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($user),
|
||||
'group_options' => $availableGroups,
|
||||
'contributor_options_by_group' => $contributorOptionsByGroup,
|
||||
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
|
||||
@@ -838,6 +890,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
|
||||
'draftId' => $id,
|
||||
'content_types' => $contentTypes,
|
||||
'suggested_tags' => [],
|
||||
'eligible_worlds' => app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($user),
|
||||
'group_options' => $availableGroups,
|
||||
'contributor_options_by_group' => $contributorOptionsByGroup,
|
||||
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
|
||||
|
||||
11
.env.example
11
.env.example
@@ -41,6 +41,14 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
# Skinbase conditional public sessions
|
||||
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
|
||||
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
|
||||
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
|
||||
|
||||
# Debug only; do not enable permanently in production
|
||||
SKINBASE_SESSION_DEBUG_HEADER=false
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
@@ -290,7 +298,7 @@ REGISTRATION_IP_PER_DAY_LIMIT=20
|
||||
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
|
||||
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
|
||||
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
|
||||
REGISTRATION_ENABLE_TURNSTILE=true
|
||||
TURNSTILE_ENABLED=false
|
||||
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
|
||||
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
|
||||
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
|
||||
@@ -298,6 +306,7 @@ REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
|
||||
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
TURNSTILE_FAIL_OPEN=false
|
||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||
TURNSTILE_TIMEOUT=5
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.deploy
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# PR Title
|
||||
feat(auth): complete registration anti-spam + email quota protection
|
||||
|
||||
## Summary
|
||||
Implements the registration anti-spam and quota hardening spec end-to-end for the email-first onboarding flow.
|
||||
|
||||
### What changed
|
||||
- Added registration anti-spam config and disposable domain config.
|
||||
- Added progressive Turnstile verification service and wiring.
|
||||
- Added registration rate limiters and route middleware (`register-ip`, `register-ip-daily`).
|
||||
- Implemented per-email cooldown and generic anti-enumeration responses.
|
||||
- Added queued verification sending job with global throttle + quota circuit breaker.
|
||||
- Added quota and disposable-domain services.
|
||||
- Hardened verification tokens (hashed storage lookup, expiry, one-time use).
|
||||
- Added/updated migrations:
|
||||
- cooldown fields on `users`
|
||||
- `email_send_events`
|
||||
- `system_email_quota`
|
||||
- token column hardening (`token` -> `token_hash`)
|
||||
- rollout safety migration to ensure `user_verification_tokens` table exists
|
||||
- Added models: `EmailSendEvent`, `SystemEmailQuota`.
|
||||
- Added/updated auth registration tests and runbook docs.
|
||||
|
||||
## Verification
|
||||
- `php artisan migrate` ✅
|
||||
- `php artisan test` ✅
|
||||
- Focused token hardening tests ✅ (`RegistrationTokenVerificationTest`)
|
||||
|
||||
## Notes
|
||||
- Current local branch: `feat/registration-antispam-complete`
|
||||
- Local commit: `b239af9`
|
||||
- Push/PR creation is currently blocked because this repo has no configured git remote and `gh` CLI is not installed.
|
||||
|
||||
## Commands to finish PR after remote setup
|
||||
```bash
|
||||
git remote add origin <your-repo-url>
|
||||
git push -u origin feat/registration-antispam-complete
|
||||
```
|
||||
|
||||
Then open PR in your Git host UI using:
|
||||
- Base: `main` (or your default branch)
|
||||
- Compare: `feat/registration-antispam-complete`
|
||||
- Body: copy this file
|
||||
@@ -352,6 +352,12 @@ YOLO may return the same tag list format as CLIP, or object detections:
|
||||
php artisan queue:work --queue=default
|
||||
```
|
||||
|
||||
- Scout/Meilisearch syncing uses the `search` queue, so artwork indexing workers must include that queue as well:
|
||||
|
||||
```bash
|
||||
php artisan queue:work --queue=search,default
|
||||
```
|
||||
|
||||
- If `VISION_QUEUE=vision`, run worker for that queue:
|
||||
|
||||
```bash
|
||||
|
||||
8
TODO.md
8
TODO.md
@@ -1,8 +0,0 @@
|
||||
# TODO SKINBASE NOVA
|
||||
|
||||
## FORUM
|
||||
|
||||
- [ ] we need to add in a main search (toolbar) and a search in the forum (search bar in the forum page)
|
||||
|
||||
## ARTWORKS
|
||||
- [ ] http://skinbase26.test/art/69601/testna-slika => we shouldnt display follow for yourself
|
||||
122
app/Console/Commands/AcademyCoursesSyncFoundationsCommand.php
Normal file
122
app/Console/Commands/AcademyCoursesSyncFoundationsCommand.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyCourseSection;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AcademyCoursesSyncFoundationsCommand extends Command
|
||||
{
|
||||
protected $signature = 'academy:courses:sync-foundations';
|
||||
|
||||
protected $description = 'Create or update the default AI-Assisted Digital Art Foundations Academy course.';
|
||||
|
||||
public function __construct(private readonly AcademyCacheService $cache)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$course = AcademyCourse::query()->updateOrCreate(
|
||||
['slug' => 'ai-assisted-digital-art-foundations'],
|
||||
[
|
||||
'title' => 'AI-Assisted Digital Art Foundations',
|
||||
'subtitle' => 'A guided path through prompting, publishing, and better Skinbase-ready workflows.',
|
||||
'excerpt' => 'Learn the foundations of AI-assisted digital art, from better prompts and ethical rules to preparing, tagging, and publishing artwork on Skinbase.',
|
||||
'description' => 'A starter course for Skinbase creators who want a structured path from core AI-art concepts to cleaner publishing-ready results.',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'published',
|
||||
'is_featured' => true,
|
||||
'order_num' => 1,
|
||||
'published_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$sectionOrder = [
|
||||
'Introduction',
|
||||
'Prompting Basics',
|
||||
'Publishing on Skinbase',
|
||||
'Workflow and Quality',
|
||||
];
|
||||
|
||||
$sections = collect($sectionOrder)->mapWithKeys(function (string $title, int $index) use ($course): array {
|
||||
$section = AcademyCourseSection::query()->updateOrCreate(
|
||||
['course_id' => $course->id, 'slug' => Str::slug($title)],
|
||||
[
|
||||
'title' => $title,
|
||||
'order_num' => $index,
|
||||
'is_visible' => true,
|
||||
],
|
||||
);
|
||||
|
||||
return [$title => $section];
|
||||
});
|
||||
|
||||
$lessonMap = [
|
||||
'Introduction' => [
|
||||
'what-is-ai-assisted-digital-art',
|
||||
'ai-ethics-and-skinbase-upload-rules',
|
||||
'ai-generated-vs-ai-assisted-artwork',
|
||||
],
|
||||
'Prompting Basics' => [
|
||||
'prompting-basics-for-skinbase-creators',
|
||||
'how-to-write-better-wallpaper-prompts',
|
||||
'understanding-style-mood-lighting-and-composition',
|
||||
],
|
||||
'Publishing on Skinbase' => [
|
||||
'how-to-prepare-ai-artwork-for-upload',
|
||||
'how-to-choose-better-tags-and-categories',
|
||||
],
|
||||
'Workflow and Quality' => [
|
||||
'how-to-avoid-common-ai-image-problems',
|
||||
'from-idea-to-artwork-a-simple-skinbase-workflow',
|
||||
],
|
||||
];
|
||||
|
||||
$orderNum = 0;
|
||||
foreach ($lessonMap as $sectionTitle => $slugs) {
|
||||
$section = $sections->get($sectionTitle);
|
||||
|
||||
foreach ($slugs as $slug) {
|
||||
$lesson = AcademyLesson::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $lesson instanceof AcademyLesson) {
|
||||
$this->warn(sprintf('Skipped missing lesson [%s].', $slug));
|
||||
continue;
|
||||
}
|
||||
|
||||
AcademyCourseLesson::query()->updateOrCreate(
|
||||
[
|
||||
'course_id' => $course->id,
|
||||
'lesson_id' => $lesson->id,
|
||||
],
|
||||
[
|
||||
'section_id' => $section?->id,
|
||||
'order_num' => $orderNum,
|
||||
'is_required' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$orderNum++;
|
||||
}
|
||||
}
|
||||
|
||||
$course->forceFill([
|
||||
'lessons_count_cache' => AcademyCourseLesson::query()->where('course_id', $course->id)->count(),
|
||||
])->save();
|
||||
|
||||
$this->cache->clearAll();
|
||||
$this->info('AI-Assisted Digital Art Foundations course synced.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
184
app/Console/Commands/AuditArtworkDownloadFilesCommand.php
Normal file
184
app/Console/Commands/AuditArtworkDownloadFilesCommand.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class AuditArtworkDownloadFilesCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:audit-download-files
|
||||
{--id= : Audit only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=500 : Number of artworks to scan per batch}
|
||||
{--restore-missing : Copy missing local originals from object storage when available}';
|
||||
|
||||
protected $description = 'Scan artworks in descending ID order and report missing local download files with full URLs.';
|
||||
|
||||
public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
|
||||
{
|
||||
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 2000));
|
||||
$restoreMissing = (bool) $this->option('restore-missing');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting download file audit. order=desc include_trashed=yes chunk=%d limit=%s restore_missing=%s',
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$restoreMissing ? 'yes' : 'no',
|
||||
));
|
||||
|
||||
$processed = 0;
|
||||
$missing = 0;
|
||||
$unresolved = 0;
|
||||
$restored = 0;
|
||||
$restoreFailed = 0;
|
||||
$lastSeenId = null;
|
||||
|
||||
do {
|
||||
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId);
|
||||
if ($artworks->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
$localPath = $locator->resolveLocalPath($artwork);
|
||||
$missingReason = null;
|
||||
|
||||
if ($localPath === '') {
|
||||
$missingReason = 'unresolved_local_path';
|
||||
$unresolved++;
|
||||
} elseif (! File::isFile($localPath)) {
|
||||
$missingReason = 'missing_local_file';
|
||||
}
|
||||
|
||||
if ($missingReason !== null) {
|
||||
$objectPath = $locator->resolveObjectPath($artwork);
|
||||
$objectUrl = $locator->resolveObjectUrl($artwork);
|
||||
|
||||
$missing++;
|
||||
$this->warn(sprintf('Artwork %d %s', (int) $artwork->id, $missingReason));
|
||||
$this->line(' artwork_url: ' . route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
]));
|
||||
$this->line(' download_url: ' . route('art.download', ['id' => (int) $artwork->id]));
|
||||
|
||||
if ($objectPath !== '') {
|
||||
$this->line(' object_path: ' . $objectPath);
|
||||
}
|
||||
|
||||
if ($objectUrl !== null && $objectUrl !== '') {
|
||||
$this->line(' object_url: ' . $objectUrl);
|
||||
}
|
||||
|
||||
if ($localPath !== '') {
|
||||
$this->line(' local_path: ' . $localPath);
|
||||
}
|
||||
|
||||
if ($restoreMissing && $missingReason === 'missing_local_file' && $localPath !== '') {
|
||||
$restoreResult = $this->restoreLocalFile($storage, $objectPath, $localPath);
|
||||
|
||||
if ($restoreResult === 'restored') {
|
||||
$restored++;
|
||||
$this->info(' restore: restored from object storage');
|
||||
} elseif ($restoreResult === 'object_missing') {
|
||||
$restoreFailed++;
|
||||
$this->warn(' restore: object storage file not found');
|
||||
} else {
|
||||
$restoreFailed++;
|
||||
$this->warn(' restore: failed to copy object to local path');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
$processed++;
|
||||
}
|
||||
|
||||
$lastSeenId = (int) $artworks->last()->id;
|
||||
} while (true);
|
||||
|
||||
$this->info(sprintf(
|
||||
'Download file audit complete. processed=%d missing=%d unresolved=%d restored=%d restore_failed=%d',
|
||||
$processed,
|
||||
$missing,
|
||||
$unresolved,
|
||||
$restored,
|
||||
$restoreFailed,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
|
||||
{
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'slug', 'file_path', 'hash', 'file_ext'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
} elseif ($lastSeenId !== null) {
|
||||
$query->where('id', '<', $lastSeenId);
|
||||
}
|
||||
|
||||
return $query->limit($chunkSize)->get();
|
||||
}
|
||||
|
||||
private function restoreLocalFile(UploadStorageService $storage, string $objectPath, string $localPath): string
|
||||
{
|
||||
if ($objectPath === '') {
|
||||
return 'object_missing';
|
||||
}
|
||||
|
||||
$disk = Storage::disk($storage->objectDiskName());
|
||||
if (! $disk->exists($objectPath)) {
|
||||
return 'object_missing';
|
||||
}
|
||||
|
||||
$stream = $disk->readStream($objectPath);
|
||||
if (! is_resource($stream)) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($localPath));
|
||||
|
||||
$target = fopen($localPath, 'wb');
|
||||
if (! is_resource($target)) {
|
||||
fclose($stream);
|
||||
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
try {
|
||||
$copied = stream_copy_to_stream($stream, $target);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
fclose($target);
|
||||
}
|
||||
|
||||
if ($copied === false || $copied <= 0 || ! File::isFile($localPath)) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
return 'restored';
|
||||
}
|
||||
}
|
||||
@@ -4,95 +4,271 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Sitemaps\BuildSitemapReleaseJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use App\Services\Sitemaps\SitemapPublishService;
|
||||
use App\Services\Sitemaps\SitemapImage;
|
||||
use App\Services\Sitemaps\SitemapIndexItem;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BuildSitemapsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:build
|
||||
{--only=* : Limit the build to one or more sitemap families}
|
||||
{--release= : Override the generated release id}
|
||||
{--shards : Show per-shard output in the command report}
|
||||
{--queue : Dispatch the release build to the queue}
|
||||
{--force : Accepted for backward compatibility; release builds are always fresh}
|
||||
{--clear : Accepted for backward compatibility; release builds are isolated}
|
||||
{--dry-run : Build a release artifact set without activating it}';
|
||||
{--only=* : Limit to specific sitemap families (comma or space separated)}
|
||||
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}
|
||||
{--progress : Show a progress bar tracking processed URLs for each family}';
|
||||
|
||||
protected $description = 'Build a versioned sitemap release artifact set.';
|
||||
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
|
||||
|
||||
public function handle(SitemapBuildService $build, SitemapPublishService $publish): int
|
||||
public function handle(SitemapBuildService $build): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$totalStart = microtime(true);
|
||||
$families = $this->selectedFamilies($build);
|
||||
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
|
||||
|
||||
if ($families === []) {
|
||||
$this->error('No valid sitemap families were selected.');
|
||||
$this->error('No valid sitemap families selected.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$showShards = (bool) $this->option('shards');
|
||||
$diskName = (string) ($this->option('disk') ?: config('sitemaps.static_publish.disk', 'sitemaps_public'));
|
||||
$disk = Storage::disk($diskName);
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
if ((bool) $this->option('queue')) {
|
||||
BuildSitemapReleaseJob::dispatch($families, $releaseId);
|
||||
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.'));
|
||||
$this->info('Disk: ' . $diskName);
|
||||
$this->info('Families: ' . implode(', ', $families));
|
||||
$this->newLine();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$manifest = $publish->buildRelease($families, $releaseId);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$totalUrls = 0;
|
||||
$totalDocuments = 0;
|
||||
|
||||
foreach ($families as $family) {
|
||||
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
|
||||
$familyUrls = 0;
|
||||
|
||||
if (! $showShards) {
|
||||
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
|
||||
}
|
||||
|
||||
foreach ($names as $name) {
|
||||
$documentType = str_ends_with((string) $name, '-index') ? 'index' : ((string) $family === (string) config('sitemaps.news.google_variant_name', 'news-google') ? 'google-news' : 'urlset');
|
||||
$familyUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
|
||||
$totalUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
|
||||
$totalDocuments++;
|
||||
|
||||
if ($showShards || ! str_contains((string) $name, '-000')) {
|
||||
// ── Root sitemap index ────────────────────────────────────────────
|
||||
$t = microtime(true);
|
||||
$this->line(' Building sitemap index…');
|
||||
$index = $build->buildIndex(force: true, persist: false, families: $families);
|
||||
$disk->put('sitemap.xml', $index['content']);
|
||||
$written++;
|
||||
$this->line(sprintf(
|
||||
' - %s [%s]',
|
||||
$name,
|
||||
$documentType,
|
||||
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
$index['url_count'],
|
||||
microtime(true) - $t,
|
||||
));
|
||||
|
||||
// ── Per-family documents ──────────────────────────────────────────
|
||||
foreach ($families as $family) {
|
||||
$familyStart = microtime(true);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if ($family === 'artworks') {
|
||||
// Direct MySQL path — no cursor-scan shard window computation
|
||||
[$shardNames, $fw, $ff] = $this->buildArtworksDirect($disk, (bool) $this->option('progress'));
|
||||
$written += $fw;
|
||||
$failed += $ff;
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>artworks</> done %d file(s) <comment>%.3fs</comment>',
|
||||
$fw,
|
||||
microtime(true) - $familyStart,
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = $build->canonicalDocumentNamesForFamily($family);
|
||||
|
||||
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
|
||||
|
||||
foreach ($names as $documentName) {
|
||||
$t = microtime(true);
|
||||
$this->line(sprintf(' Building %s…', $documentName));
|
||||
|
||||
$built = $build->buildNamed($documentName, force: true, persist: false);
|
||||
|
||||
if ($built === null) {
|
||||
$this->line(sprintf(' <comment>–</comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->put('sitemaps/' . $documentName . '.xml', $built['content']);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
|
||||
$documentName . '.xml',
|
||||
$built['url_count'] ?? 0,
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
|
||||
$family,
|
||||
microtime(true) - $familyStart,
|
||||
));
|
||||
}
|
||||
|
||||
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names)));
|
||||
}
|
||||
$totalDocuments++;
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs',
|
||||
(string) $manifest['release_id'],
|
||||
(int) data_get($manifest, 'totals.families', 0),
|
||||
(int) data_get($manifest, 'totals.documents', 0),
|
||||
(int) data_get($manifest, 'totals.urls', 0),
|
||||
(string) ($manifest['status'] ?? 'built'),
|
||||
microtime(true) - $startedAt,
|
||||
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
|
||||
$written,
|
||||
$failed,
|
||||
microtime(true) - $totalStart,
|
||||
));
|
||||
$this->line('Sitemap index complete');
|
||||
|
||||
return self::SUCCESS;
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream artworks directly from MySQL using chunkById — avoids cursor-scan shard windows.
|
||||
*
|
||||
* @return array{0: list<string>, 1: int, 2: int} [shardNames, written, failed]
|
||||
*/
|
||||
private function buildArtworksDirect(Filesystem $disk, bool $showProgress = false): array
|
||||
{
|
||||
$chunkSize = max(1, (int) config('sitemaps.shards.artworks.size', 10_000));
|
||||
$padLen = max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
|
||||
$shardNum = 0;
|
||||
$shardNames = [];
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
$baseQuery = Artwork::query()
|
||||
->public()
|
||||
->published();
|
||||
|
||||
$total = $showProgress ? (clone $baseQuery)->count() : null;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>artworks</> (chunk size: %d%s)',
|
||||
$chunkSize,
|
||||
$total !== null ? ', total: ' . number_format($total) : '',
|
||||
));
|
||||
|
||||
$bar = null;
|
||||
if ($total !== null) {
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% shard %message% elapsed: %elapsed:6s% mem: %memory:6s%');
|
||||
$bar->setMessage('—');
|
||||
$bar->start();
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$baseQuery
|
||||
->select(['id', 'slug', 'title', 'updated_at', 'published_at', 'created_at', 'hash', 'file_path', 'file_name'])
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($artworks) use ($disk, $padLen, $bar, &$shardNum, &$shardNames, &$written, &$failed): void {
|
||||
$shardNum++;
|
||||
$t = microtime(true);
|
||||
$name = 'artworks-' . str_pad((string) $shardNum, $padLen, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($bar !== null) {
|
||||
$bar->setMessage($name);
|
||||
} else {
|
||||
$this->line(sprintf(' Building %s (%d rows)…', $name, $artworks->count()));
|
||||
}
|
||||
|
||||
/** @var list<SitemapUrl> $items */
|
||||
$items = $artworks
|
||||
->map(fn (Artwork $artwork): ?SitemapUrl => $this->artworkSitemapUrl($artwork))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$xml = view('sitemaps.urlset', [
|
||||
'items' => $items,
|
||||
'hasImages' => collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
|
||||
])->render();
|
||||
|
||||
if (! $disk->put('sitemaps/' . $name . '.xml', $xml)) {
|
||||
if ($bar !== null) {
|
||||
$bar->advance($artworks->count());
|
||||
$this->newLine();
|
||||
}
|
||||
$this->line(sprintf(' <comment>–</comment> %s <fg=red>WRITE FAILED</>', $name . '.xml'));
|
||||
$failed++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$shardNames[] = $name;
|
||||
$written++;
|
||||
|
||||
if ($bar !== null) {
|
||||
$bar->advance($artworks->count());
|
||||
} else {
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
|
||||
$name . '.xml',
|
||||
count($items),
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
if ($bar !== null) {
|
||||
$bar->setMessage('done');
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($shardNames === []) {
|
||||
return [[], 0, $failed];
|
||||
}
|
||||
|
||||
// Write artworks-index.xml when there are multiple shards (matches SitemapShardService behaviour)
|
||||
if (count($shardNames) > 1) {
|
||||
$t = microtime(true);
|
||||
$indexItems = array_map(
|
||||
fn (string $n): SitemapIndexItem => new SitemapIndexItem(url('/sitemaps/' . $n . '.xml')),
|
||||
$shardNames,
|
||||
);
|
||||
|
||||
$indexXml = view('sitemaps.index', ['items' => $indexItems])->render();
|
||||
$disk->put('sitemaps/artworks-index.xml', $indexXml);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> artworks-index.xml %d shards <comment>%.3fs</comment>',
|
||||
count($shardNames),
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
return [$shardNames, $written, $failed];
|
||||
}
|
||||
|
||||
private function artworkSitemapUrl(Artwork $artwork): ?SitemapUrl
|
||||
{
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($slug === '') {
|
||||
$slug = (string) $artwork->id;
|
||||
}
|
||||
|
||||
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||
|
||||
$images = [];
|
||||
if (! empty($preview['url'])) {
|
||||
$images[] = new SitemapImage((string) $preview['url'], $artwork->title ?: null);
|
||||
}
|
||||
|
||||
$timestamps = array_filter(array_map(
|
||||
static fn (mixed $v): ?Carbon => $v instanceof Carbon ? $v : (is_string($v) ? Carbon::parse($v) : null),
|
||||
[$artwork->updated_at, $artwork->published_at, $artwork->created_at],
|
||||
));
|
||||
|
||||
usort($timestamps, static fn (Carbon $a, Carbon $b): int => $b->timestamp <=> $a->timestamp);
|
||||
|
||||
return new SitemapUrl(
|
||||
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||
$timestamps[0] ?? null,
|
||||
$images,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,7 +58,9 @@ class ConfigureMeilisearchIndex extends Command
|
||||
'maturity_status',
|
||||
'has_missing_thumbnails',
|
||||
'category',
|
||||
'categories',
|
||||
'content_type',
|
||||
'content_types',
|
||||
'published_as_type',
|
||||
'tags',
|
||||
'author_id',
|
||||
|
||||
186
app/Console/Commands/ExportLegacyNewsCommentsSqlCommand.php
Normal file
186
app/Console/Commands/ExportLegacyNewsCommentsSqlCommand.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExportLegacyNewsCommentsSqlCommand extends Command
|
||||
{
|
||||
protected $signature = 'news:comments-export-legacy-sql
|
||||
{--path=database/sql/news_article_comments_legacy_import.sql : Output SQL file path}
|
||||
{--skip-empty : Skip comments with empty or whitespace-only content}
|
||||
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
|
||||
|
||||
protected $description = 'Generate a production-safe SQL file for legacy news comments import';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$legacyTable = $this->resolveLegacyTable();
|
||||
if ($legacyTable === null) {
|
||||
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$outputPath = $this->resolveOutputPath((string) $this->option('path'));
|
||||
$skipEmpty = (bool) $this->option('skip-empty');
|
||||
|
||||
$directory = dirname($outputPath);
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$handle = fopen($outputPath, 'wb');
|
||||
if ($handle === false) {
|
||||
$this->error('Unable to write SQL file: ' . $outputPath);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$written = 0;
|
||||
$skippedEmpty = 0;
|
||||
$legacyNewsIds = [];
|
||||
|
||||
fwrite($handle, "-- Legacy news comments import generated at " . now()->toDateTimeString() . PHP_EOL);
|
||||
fwrite($handle, "START TRANSACTION;" . PHP_EOL . PHP_EOL);
|
||||
|
||||
DB::connection('legacy')
|
||||
->table($legacyTable)
|
||||
->orderBy('comment_id')
|
||||
->chunk(500, function ($rows) use ($handle, $skipEmpty, &$written, &$skippedEmpty, &$legacyNewsIds): void {
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) ($row->comment_id ?? 0);
|
||||
$legacyNewsId = (int) ($row->news_id ?? 0);
|
||||
$legacyUserId = (int) ($row->user_id ?? 0);
|
||||
$body = trim((string) ($row->message ?? ''));
|
||||
|
||||
if ($legacyId < 1 || $legacyNewsId < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($body === '') {
|
||||
if ($skipEmpty) {
|
||||
$skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = '[no content]';
|
||||
}
|
||||
|
||||
$legacyNewsIds[$legacyNewsId] = $legacyNewsId;
|
||||
|
||||
$authorName = trim((string) ($row->author ?? ''));
|
||||
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
|
||||
$renderedBody = nl2br(e($body));
|
||||
$userExpression = $legacyUserId > 0
|
||||
? "CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = {$legacyUserId} AND users.deleted_at IS NULL) THEN {$legacyUserId} ELSE NULL END"
|
||||
: 'NULL';
|
||||
|
||||
$statement = "INSERT IGNORE INTO news_article_comments (legacy_id, legacy_user_id, article_id, user_id, parent_id, author_name, body, rendered_body, status, legacy_posted_at, created_at, updated_at, deleted_at)\n"
|
||||
. "SELECT {$legacyId}, " . ($legacyUserId > 0 ? (string) $legacyUserId : 'NULL') . ", news_articles.id, {$userExpression}, NULL, " . $this->quote($authorName !== '' ? $authorName : null) . ", " . $this->quote($body) . ", " . $this->quote($renderedBody) . ", 'visible', " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", NULL\n"
|
||||
. "FROM news_articles\n"
|
||||
. "WHERE news_articles.legacy_news_id = {$legacyNewsId}\n"
|
||||
. "LIMIT 1;\n\n";
|
||||
|
||||
fwrite($handle, $statement);
|
||||
$written++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($legacyNewsIds !== []) {
|
||||
foreach (array_chunk(array_values($legacyNewsIds), 250) as $chunk) {
|
||||
fwrite($handle, 'UPDATE news_articles SET comments_enabled = 1 WHERE legacy_news_id IN (' . implode(', ', array_map('intval', $chunk)) . ');' . PHP_EOL);
|
||||
}
|
||||
|
||||
fwrite($handle, PHP_EOL);
|
||||
}
|
||||
|
||||
fwrite($handle, 'COMMIT;' . PHP_EOL);
|
||||
fclose($handle);
|
||||
|
||||
$this->info('SQL export written to ' . $outputPath);
|
||||
$this->table(
|
||||
['Result', 'Count'],
|
||||
[
|
||||
['Statements written', $written],
|
||||
['Skipped - empty body', $skippedEmpty],
|
||||
['Articles enabled for comments', count($legacyNewsIds)],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveLegacyTable(): ?string
|
||||
{
|
||||
$configured = trim((string) $this->option('table'));
|
||||
if ($configured !== '') {
|
||||
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
|
||||
}
|
||||
|
||||
foreach (['news_comment', 'news_comments'] as $candidate) {
|
||||
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveOutputPath(string $path): string
|
||||
{
|
||||
$trimmed = trim($path);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return base_path('database/sql/news_article_comments_legacy_import.sql');
|
||||
}
|
||||
|
||||
if (preg_match('/^[A-Za-z]:\\\\|^\\\\\\\\|^\//', $trimmed) === 1) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return base_path(str_replace(['/', '\\\\'], DIRECTORY_SEPARATOR, $trimmed));
|
||||
}
|
||||
|
||||
private function normalizeTimestamp(mixed $value): string
|
||||
{
|
||||
$raw = trim((string) ($value ?? ''));
|
||||
|
||||
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
|
||||
return now()->toDateTimeString();
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return now()->toDateTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
private function quote(?string $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
$escaped = str_replace(
|
||||
["\\", "\0", "\n", "\r", "\x1a", "'"],
|
||||
["\\\\", "\\0", "\\n", "\\r", "\\Z", "\\'"],
|
||||
$value,
|
||||
);
|
||||
|
||||
return "'{$escaped}'";
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,13 @@ class ExportLegacyPasswordsCommand extends Command
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->select(['user_id', 'password2', 'password'])
|
||||
->where('should_migrate', 1)
|
||||
->orderBy('user_id')
|
||||
->chunk($chunk, function ($rows) use (&$lines, &$exported, $now) {
|
||||
foreach ($rows as $r) {
|
||||
$id = (int) ($r->user_id ?? 0);
|
||||
$hash = trim((string) ($r->password2 ?: $r->password ?: ''));
|
||||
if ($id === 0 || $hash === '') {
|
||||
if ($id === 0 || $hash === '' || $hash === 'abc123') {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
181
app/Console/Commands/ForceIndexArtworkCommand.php
Normal file
181
app/Console/Commands/ForceIndexArtworkCommand.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
/**
|
||||
* Directly write a single artwork into the Meilisearch index, bypassing the queue.
|
||||
*
|
||||
* Useful when:
|
||||
* - A rebuild was run but the queue worker was not consuming the `search` queue.
|
||||
* - A specific artwork is missing from the live index and you want it visible immediately.
|
||||
* - You need to force-push a corrected document after schema changes.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan artworks:search-force-index 69810
|
||||
* php artisan artworks:search-force-index # interactive prompt
|
||||
* php artisan artworks:search-force-index 69810 --dry-run
|
||||
* php artisan artworks:search-force-index 69810 --force # index even if not public/approved/published
|
||||
*/
|
||||
final class ForceIndexArtworkCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-force-index
|
||||
{artwork_id? : The artwork ID to force-index}
|
||||
{--index= : Override the Meilisearch index name}
|
||||
{--dry-run : Show what would be sent without actually writing}
|
||||
{--force : Index the document even when the artwork is not public/approved/published}
|
||||
{--no-cache-bump : Skip bumping the explore cache version after indexing}';
|
||||
|
||||
protected $description = 'Directly push a single artwork into Meilisearch, bypassing the queue.';
|
||||
|
||||
public function handle(MeilisearchClient $client): int
|
||||
{
|
||||
$artworkId = $this->resolveArtworkId();
|
||||
|
||||
if ($artworkId === null) {
|
||||
$this->error('An artwork ID is required.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
$forceIndex = (bool) $this->option('force');
|
||||
|
||||
$this->line(sprintf(
|
||||
'%sForce-indexing artwork #%d into Meilisearch%s…',
|
||||
$isDryRun ? '[DRY RUN] ' : '',
|
||||
$artworkId,
|
||||
$forceIndex ? ' (--force: eligibility check bypassed)' : '',
|
||||
));
|
||||
|
||||
// ── 1. Load artwork with all relations required for toSearchableArray() ──
|
||||
$artwork = Artwork::query()
|
||||
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
|
||||
->find($artworkId);
|
||||
|
||||
if ($artwork === null) {
|
||||
$this->error("Artwork #{$artworkId} was not found in the database.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->comment('Artwork');
|
||||
$this->line(sprintf(
|
||||
' id=%d title="%s" public=%s approved=%s published_at=%s',
|
||||
(int) $artwork->id,
|
||||
(string) ($artwork->title ?? ''),
|
||||
$artwork->is_public ? 'yes' : 'no',
|
||||
$artwork->is_approved ? 'yes' : 'no',
|
||||
$artwork->published_at?->toIso8601String() ?? 'null',
|
||||
));
|
||||
|
||||
// ── 2. Eligibility check ─────────────────────────────────────────────────
|
||||
$shouldBeIndexed = $artwork->is_public && $artwork->is_approved && $artwork->published_at !== null;
|
||||
|
||||
if (! $shouldBeIndexed && ! $forceIndex) {
|
||||
$this->warn(sprintf(
|
||||
'Artwork #%d is not eligible for the public index (is_public=%s, is_approved=%s, published_at=%s). ' .
|
||||
'Use --force to index it anyway, or fix the artwork status first.',
|
||||
$artworkId,
|
||||
$artwork->is_public ? 'true' : 'false',
|
||||
$artwork->is_approved ? 'true' : 'false',
|
||||
$artwork->published_at?->toIso8601String() ?? 'null',
|
||||
));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $shouldBeIndexed && $forceIndex) {
|
||||
$this->warn('Artwork is not normally eligible but --force was passed; indexing anyway.');
|
||||
}
|
||||
|
||||
// ── 3. Build the Meilisearch document ────────────────────────────────────
|
||||
$document = $artwork->toSearchableArray();
|
||||
|
||||
$this->comment('Generated document');
|
||||
$this->line(json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
$this->newLine();
|
||||
|
||||
// ── 4. Resolve index name ────────────────────────────────────────────────
|
||||
$indexName = $this->resolveIndexName($artwork);
|
||||
$this->line("Target index: {$indexName}");
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info('[DRY RUN] Document was NOT written to Meilisearch. Remove --dry-run to execute.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── 5. Write directly to Meilisearch (no queue) ──────────────────────────
|
||||
try {
|
||||
$taskResult = $client->index($indexName)->addDocuments([$document]);
|
||||
$taskUid = $taskResult['taskUid'] ?? $taskResult['uid'] ?? 'n/a';
|
||||
$this->info(sprintf(
|
||||
'Document written to Meilisearch. Task uid: %s',
|
||||
is_scalar($taskUid) ? (string) $taskUid : json_encode($taskUid),
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Meilisearch write failed: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ── 6. Bump explore cache version ────────────────────────────────────────
|
||||
if (! $this->option('no-cache-bump')) {
|
||||
try {
|
||||
$newVersion = ((int) Cache::get('explore.cache.version', 1)) + 1;
|
||||
Cache::forever('explore.cache.version', $newVersion);
|
||||
$this->line("Explore cache version bumped to {$newVersion}.");
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn('Could not bump explore cache version: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Summary ────────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Artwork #%d ("%s") has been pushed to index "%s" directly.',
|
||||
(int) $artwork->id,
|
||||
(string) ($artwork->title ?? ''),
|
||||
$indexName,
|
||||
));
|
||||
$this->line('The artwork should now appear on browse and search pages.');
|
||||
$this->line('If Meilisearch was still processing the task you can verify with:');
|
||||
$this->line(sprintf(' php artisan artworks:search-inspect %d', $artworkId));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveArtworkId(): ?int
|
||||
{
|
||||
$argument = $this->argument('artwork_id');
|
||||
|
||||
if ($argument !== null && $argument !== '') {
|
||||
return max(1, (int) $argument);
|
||||
}
|
||||
|
||||
if (! $this->input->isInteractive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$answer = $this->ask('Artwork ID');
|
||||
|
||||
if ($answer === null || trim($answer) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(1, (int) $answer);
|
||||
}
|
||||
|
||||
private function resolveIndexName(Artwork $artwork): string
|
||||
{
|
||||
$override = trim((string) $this->option('index'));
|
||||
|
||||
if ($override !== '') {
|
||||
return $override;
|
||||
}
|
||||
|
||||
return $artwork->searchableAs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Featured\FeaturedArtworkSelector;
|
||||
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
final class GenerateFeaturedArtworkThumbnailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:featured-thumbnails:generate
|
||||
{--artwork=* : Restrict generation to one or more artwork IDs}
|
||||
{--only-featured : Restrict generation to currently selected featured artworks}
|
||||
{--missing-only : Only generate artworks missing at least one featured variant}
|
||||
{--all : Process all artworks with a hash and source extension}
|
||||
{--limit=0 : Cap the number of artworks processed}
|
||||
{--queue : Dispatch background jobs instead of generating inline}
|
||||
{--force : Regenerate all featured variants even when they already exist}
|
||||
{--dry-run : Report planned generation without writing files}';
|
||||
|
||||
protected $description = 'Generate dedicated featured artwork CDN thumbnails for the homepage hero';
|
||||
|
||||
public function __construct(
|
||||
private readonly FeaturedArtworkSelector $selector,
|
||||
private readonly FeaturedArtworkThumbnailGenerator $generator,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$artworkIds = collect((array) $this->option('artwork'))
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->filter(static fn (int $id): bool => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$all = (bool) $this->option('all');
|
||||
$explicitOnlyFeatured = (bool) $this->option('only-featured');
|
||||
$missingOnly = $force ? false : ((bool) $this->option('missing-only') || ($artworkIds === [] && ! $all));
|
||||
|
||||
if ($all && $explicitOnlyFeatured) {
|
||||
$this->error('Use either --all or --only-featured, not both.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
if ($queue && $dryRun) {
|
||||
$this->error('Use either --queue or --dry-run, not both.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$onlyFeatured = $artworkIds === [] && ! $all;
|
||||
if ($explicitOnlyFeatured) {
|
||||
$onlyFeatured = true;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generatedVariants = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($this->candidateArtworks($artworkIds, $onlyFeatured) as $artwork) {
|
||||
if ($limit > 0 && $processed >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
$plan = $this->generator->plan($artwork, $force);
|
||||
$targetVariants = (array) ($plan['target_variants'] ?? []);
|
||||
|
||||
if ($missingOnly && ! $force && $targetVariants === []) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'[dry-run] artwork=%d variants=%s',
|
||||
(int) $artwork->id,
|
||||
$targetVariants === [] ? 'none' : implode(',', $targetVariants),
|
||||
));
|
||||
|
||||
if ($targetVariants === []) {
|
||||
$skipped++;
|
||||
} else {
|
||||
$generatedVariants += count($targetVariants);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
GenerateFeaturedArtworkThumbnailsJob::dispatch((int) $artwork->id, $force);
|
||||
$queued++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->generator->generate($artwork, $force);
|
||||
|
||||
$generatedVariants += (int) ($result['generated'] ?? 0);
|
||||
$skipped += count((array) ($result['target_variants'] ?? [])) === 0 ? 1 : 0;
|
||||
|
||||
if (($result['failed'] ?? []) !== []) {
|
||||
$failed++;
|
||||
$this->warn(sprintf(
|
||||
'Artwork %d failed for variants: %s',
|
||||
(int) $artwork->id,
|
||||
implode(', ', array_keys((array) $result['failed'])),
|
||||
));
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
$mode = $dryRun ? 'dry-run' : ($queue ? 'queued' : 'generated');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Featured artwork thumbnail %s complete: processed=%d queued=%d generated_variants=%d skipped=%d failed=%d',
|
||||
$mode,
|
||||
$processed,
|
||||
$queued,
|
||||
$generatedVariants,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $artworkIds
|
||||
* @return LazyCollection<int, Artwork>
|
||||
*/
|
||||
private function candidateArtworks(array $artworkIds, bool $onlyFeatured): LazyCollection
|
||||
{
|
||||
if ($artworkIds !== []) {
|
||||
return Artwork::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $artworkIds)
|
||||
->whereNotNull('hash')
|
||||
->where('hash', '!=', '')
|
||||
->whereNotNull('file_ext')
|
||||
->where('file_ext', '!=', '')
|
||||
->orderByDesc('id')
|
||||
->cursor();
|
||||
}
|
||||
|
||||
$query = $onlyFeatured
|
||||
? $this->selector->querySelectedArtworks()
|
||||
: Artwork::query()
|
||||
->select('artworks.*')
|
||||
->withTrashed()
|
||||
->whereNotNull('hash')
|
||||
->where('hash', '!=', '')
|
||||
->whereNotNull('file_ext')
|
||||
->where('file_ext', '!=', '');
|
||||
|
||||
return $this->orderedCursor($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LazyCollection<int, Artwork>
|
||||
*/
|
||||
private function orderedCursor(Builder $query): LazyCollection
|
||||
{
|
||||
return $query
|
||||
->orderByDesc('artworks.id')
|
||||
->cursor();
|
||||
}
|
||||
}
|
||||
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\News\NewsCoverImageService;
|
||||
use App\Support\News\NewsCoverImage;
|
||||
use Illuminate\Console\Command;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class GenerateNewsCoverThumbnailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
|
||||
|
||||
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
|
||||
|
||||
public function __construct(
|
||||
private readonly NewsCoverImageService $covers,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = collect((array) $this->option('id'))
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->filter(static fn (int $id): bool => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
$query = NewsArticle::query()
|
||||
->select(['id', 'title', 'cover_image'])
|
||||
->whereNotNull('cover_image')
|
||||
->where('cover_image', '!=', '');
|
||||
|
||||
if ($ids !== []) {
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$purged = 0;
|
||||
|
||||
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
|
||||
foreach ($articles as $article) {
|
||||
$path = trim((string) $article->cover_image);
|
||||
|
||||
if (! NewsCoverImage::isManagedPath($path)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->covers->ensureVariants($path, $force);
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($result['generated'] ?? 0) > 0) {
|
||||
$generated++;
|
||||
|
||||
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
|
||||
$purged++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipped++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function purgeVariantCache(string $path, int $articleId): bool
|
||||
{
|
||||
$variantPaths = array_values(array_map(
|
||||
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
|
||||
array_keys(NewsCoverImage::VARIANTS),
|
||||
));
|
||||
|
||||
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
|
||||
'article_id' => $articleId,
|
||||
'reason' => 'news_cover_thumbnails_regenerated',
|
||||
]);
|
||||
}
|
||||
}
|
||||
133
app/Console/Commands/GenerateSitemapsCommand.php
Normal file
133
app/Console/Commands/GenerateSitemapsCommand.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Builds all sitemap documents and writes them as static .xml files to the
|
||||
* public disk (default: public/sitemaps/sitemap.xml and public/sitemaps/{name}.xml).
|
||||
*
|
||||
* Nginx can then serve those files directly (try_files $uri @php) without
|
||||
* hitting PHP at all. The SitemapController falls back to these same files
|
||||
* on the PHP path if a request does reach it before a static file exists.
|
||||
*
|
||||
* Run manually: php artisan skinbase:sitemaps:generate
|
||||
* With filtering: php artisan skinbase:sitemaps:generate --only=artworks,users
|
||||
*/
|
||||
final class GenerateSitemapsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:generate
|
||||
{--only=* : Limit to specific sitemap families (comma or space separated)}
|
||||
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
|
||||
|
||||
protected $description = 'Build all sitemaps and write them as static .xml files to the configured public sitemap disk.';
|
||||
|
||||
public function handle(SitemapBuildService $build): int
|
||||
{
|
||||
$totalStart = microtime(true);
|
||||
$families = $this->selectedFamilies($build);
|
||||
|
||||
if ($families === []) {
|
||||
$this->error('No valid sitemap families selected.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$diskName = (string) ($this->option('disk') ?: config('sitemaps.static_publish.disk', 'sitemaps_public'));
|
||||
$disk = Storage::disk($diskName);
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
$this->info('Disk: ' . $diskName);
|
||||
$this->info('Families: ' . implode(', ', $families));
|
||||
$this->newLine();
|
||||
|
||||
// ── Root sitemap index ────────────────────────────────────────────
|
||||
$t = microtime(true);
|
||||
$index = $build->buildIndex(force: true, persist: false, families: $families);
|
||||
$disk->put('sitemaps/sitemap.xml', $index['content']);
|
||||
$written++;
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
$index['url_count'],
|
||||
microtime(true) - $t,
|
||||
));
|
||||
|
||||
// ── Per-family documents ──────────────────────────────────────────
|
||||
foreach ($families as $family) {
|
||||
$familyStart = microtime(true);
|
||||
$names = $build->canonicalDocumentNamesForFamily($family);
|
||||
|
||||
$this->newLine();
|
||||
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
|
||||
|
||||
foreach ($names as $documentName) {
|
||||
$t = microtime(true);
|
||||
$built = $build->buildNamed($documentName, force: true, persist: false);
|
||||
|
||||
if ($built === null) {
|
||||
$this->line(sprintf(' <comment>–</comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = 'sitemaps/' . $documentName . '.xml';
|
||||
$disk->put($path, $built['content']);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
|
||||
$documentName . '.xml',
|
||||
$built['url_count'] ?? 0,
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
|
||||
$family,
|
||||
microtime(true) - $familyStart,
|
||||
));
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
|
||||
$written,
|
||||
$failed,
|
||||
microtime(true) - $totalStart,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function selectedFamilies(SitemapBuildService $build): array
|
||||
{
|
||||
$only = [];
|
||||
|
||||
foreach ((array) $this->option('only') as $value) {
|
||||
foreach (explode(',', (string) $value) as $family) {
|
||||
$normalized = trim($family);
|
||||
if ($normalized !== '') {
|
||||
$only[] = $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$enabled = $build->enabledFamilies();
|
||||
|
||||
if ($only === []) {
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
return array_values(array_filter($enabled, fn (string $f): bool => in_array($f, $only, true)));
|
||||
}
|
||||
}
|
||||
217
app/Console/Commands/HashLegacyPlainPasswordsCommand.php
Normal file
217
app/Console/Commands/HashLegacyPlainPasswordsCommand.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Reads plain-text passwords from the legacy `users` table, bcrypt-hashes
|
||||
* them, and writes a SQL UPDATE file ready to run against the new database.
|
||||
*
|
||||
* For users whose password is 'abc123' a strong random password is generated
|
||||
* first so they are not left with a known weak credential.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:hash-legacy-plain-passwords
|
||||
* php artisan skinbase:hash-legacy-plain-passwords --out=storage/app/hashed-passwords.sql
|
||||
* php artisan skinbase:hash-legacy-plain-passwords --chunk=1000
|
||||
*/
|
||||
class HashLegacyPlainPasswordsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:hash-legacy-plain-passwords
|
||||
{--out= : Output SQL file path (default: storage/app/hashed-plain-passwords.sql)}
|
||||
{--chunk=500 : Chunk size for reading legacy users}
|
||||
{--legacy-connection=legacy : Name of the legacy DB connection}
|
||||
{--legacy-table=users : Name of the legacy users table}
|
||||
{--dry-run : Print row count without writing the SQL file}';
|
||||
|
||||
protected $description = 'Hash plain-text legacy passwords with bcrypt and export UPDATE SQL. Randomises weak \'abc123\' passwords.';
|
||||
|
||||
// Characters for random password generation (no ambiguous l/1/0/O)
|
||||
private const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
private const LOWER = 'abcdefghjkmnpqrstuvwxyz';
|
||||
private const DIGITS = '23456789';
|
||||
private const SPECIAL = '!@#$%^&*';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$outPath = $this->option('out') ?: storage_path('app/hashed-plain-passwords.sql');
|
||||
$chunk = max(1, (int) ($this->option('chunk') ?? 500));
|
||||
$legacyConn = (string) ($this->option('legacy-connection') ?? 'legacy');
|
||||
$legacyTable = (string) ($this->option('legacy-table') ?? 'users');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Verify legacy connection is available
|
||||
try {
|
||||
DB::connection($legacyConn)->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Cannot connect to legacy DB: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now()->format('Y-m-d H:i:s');
|
||||
$newDbName = DB::getDatabaseName();
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '-- Hashed plain-password export';
|
||||
$lines[] = '-- Generated: ' . $now;
|
||||
$lines[] = '-- Source: legacy DB (read-only) — passwords bcrypt-hashed for Laravel';
|
||||
$lines[] = '-- WARNING: this file contains sensitive data. Delete after applying.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'SET NAMES utf8mb4;';
|
||||
$lines[] = 'USE `' . $newDbName . '`;';
|
||||
$lines[] = 'START TRANSACTION;';
|
||||
$lines[] = '';
|
||||
|
||||
$processed = 0;
|
||||
$randomised = 0;
|
||||
$skipped = 0;
|
||||
$chunkNum = 0;
|
||||
|
||||
// Count total for progress bar
|
||||
$total = DB::connection($legacyConn)
|
||||
->table($legacyTable)
|
||||
->where('should_migrate', 1)
|
||||
->count();
|
||||
|
||||
$this->info("Legacy DB: {$total} users with should_migrate=1 found.");
|
||||
$this->info("Output : " . ($dryRun ? '(dry-run, no file)' : $outPath));
|
||||
$this->newLine();
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(" %current%/%max% [%bar%] %percent:3s%% mem:%memory:6s%\n %message%");
|
||||
$bar->setMessage('Starting…');
|
||||
$bar->start();
|
||||
|
||||
DB::connection($legacyConn)
|
||||
->table($legacyTable)
|
||||
->select(['user_id', 'password'])
|
||||
->where('should_migrate', 1)
|
||||
->orderBy('user_id')
|
||||
->chunk($chunk, function ($rows) use (&$lines, &$processed, &$randomised, &$skipped, &$chunkNum, $now, $bar, $chunk) {
|
||||
$chunkNum++;
|
||||
$bar->setMessage("chunk #{$chunkNum} (chunk size {$chunk})");
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$userId = (int) ($row->user_id ?? 0);
|
||||
$plain = trim((string) ($row->password ?? ''));
|
||||
|
||||
if ($userId <= 0 || $plain === '') {
|
||||
$bar->setMessage("user_id={$userId} SKIPPED (empty)");
|
||||
$bar->advance();
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip entries that already look like a bcrypt / argon hash
|
||||
if (preg_match('/^\$2[aby]\$|^\$argon2/', $plain)) {
|
||||
$lines[] = "-- USER ID: {$userId} (already hashed — skipped)";
|
||||
$lines[] = '';
|
||||
$bar->setMessage("user_id={$userId} SKIPPED (already hashed)");
|
||||
$bar->advance();
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$commentPlain = $plain;
|
||||
$tag = 'hashed';
|
||||
|
||||
if ($plain === 'abc123') {
|
||||
$newPlain = $this->generateStrongPassword();
|
||||
$commentPlain = "abc123 => {$newPlain}";
|
||||
$plain = $newPlain;
|
||||
$tag = 'RANDOMISED (was abc123)';
|
||||
$randomised++;
|
||||
}
|
||||
|
||||
$bcrypt = Hash::make($plain);
|
||||
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $bcrypt);
|
||||
|
||||
$lines[] = "-- USER ID: {$userId} PASS: {$commentPlain}";
|
||||
$lines[] = "SAVEPOINT sp_{$userId};";
|
||||
$lines[] = "UPDATE `users` SET `password` = '{$escaped}' WHERE `id` = {$userId};";
|
||||
$lines[] = '';
|
||||
|
||||
$bar->setMessage("user_id={$userId} {$tag}");
|
||||
$bar->advance();
|
||||
$processed++;
|
||||
}
|
||||
});
|
||||
|
||||
$bar->setMessage("Done.");
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$lines[] = 'COMMIT;';
|
||||
$lines[] = '';
|
||||
$lines[] = "-- Total processed : {$processed}";
|
||||
$lines[] = "-- Passwords randomised (abc123) : {$randomised}";
|
||||
$lines[] = "-- Rows skipped (empty / already hashed) : {$skipped}";
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Processed (hashed)', $processed],
|
||||
['Randomised (abc123)', $randomised],
|
||||
['Skipped', $skipped],
|
||||
['Total should_migrate=1', $total],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('Dry-run mode — SQL file not written.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dir = dirname($outPath);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0750, true)) {
|
||||
$this->error("Cannot create output directory: {$dir}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$sql = implode("\n", $lines) . "\n";
|
||||
|
||||
if (file_put_contents($outPath, $sql) === false) {
|
||||
$this->error("Cannot write SQL file: {$outPath}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("SQL written to: {$outPath}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random strong password.
|
||||
* Format: 4 upper + 4 lower + 3 digits + 2 special = 13 chars, then shuffled.
|
||||
*/
|
||||
private function generateStrongPassword(): string
|
||||
{
|
||||
$password = '';
|
||||
$password .= $this->randomChars(self::UPPER, 4);
|
||||
$password .= $this->randomChars(self::LOWER, 4);
|
||||
$password .= $this->randomChars(self::DIGITS, 3);
|
||||
$password .= $this->randomChars(self::SPECIAL, 2);
|
||||
|
||||
// Shuffle with a cryptographically random permutation
|
||||
$chars = str_split($password);
|
||||
for ($i = count($chars) - 1; $i > 0; $i--) {
|
||||
$j = random_int(0, $i);
|
||||
[$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]];
|
||||
}
|
||||
|
||||
return implode('', $chars);
|
||||
}
|
||||
|
||||
private function randomChars(string $pool, int $count): string
|
||||
{
|
||||
$out = '';
|
||||
$max = strlen($pool) - 1;
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$out .= $pool[random_int(0, $max)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1010,7 +1010,7 @@ class HealthCheckCommand extends Command
|
||||
|
||||
private function checkScheduler(): void
|
||||
{
|
||||
// The scheduler tick key is written by Kernel::schedule() via a ->then() callback.
|
||||
// The scheduler tick key is written by the scheduled health:tick command.
|
||||
// If Redis is not the cache driver, we can't check it.
|
||||
if (config('cache.default') !== 'redis' && config('queue.default') !== 'redis') {
|
||||
$this->warn_check('scheduler', 'Scheduler check requires Redis cache or queue — skipping in this environment.');
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -87,6 +86,8 @@ class ImportLegacyNewsCommand extends Command
|
||||
'is_pinned' => ($row->type ?? 0) == 2,
|
||||
'views' => $row->views ?? 0,
|
||||
'canonical_url' => '/legacy/news/' . ($row->news_id ?? ''),
|
||||
'legacy_news_id' => isset($row->news_id) ? (int) $row->news_id : null,
|
||||
'comments_enabled' => false,
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
|
||||
229
app/Console/Commands/ImportLegacyNewsCommentsCommand.php
Normal file
229
app/Console/Commands/ImportLegacyNewsCommentsCommand.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportLegacyNewsCommentsCommand extends Command
|
||||
{
|
||||
protected $signature = 'news:comments-import-legacy
|
||||
{--dry-run : Preview only — no writes to DB}
|
||||
{--chunk=500 : Rows to process per batch}
|
||||
{--skip-empty : Skip comments with empty or whitespace-only content}
|
||||
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
|
||||
|
||||
protected $description = 'Import legacy news comments into news_article_comments';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$skipEmpty = (bool) $this->option('skip-empty');
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$legacyTable = $this->resolveLegacyTable();
|
||||
if ($legacyTable === null) {
|
||||
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::getSchemaBuilder()->hasTable('news_article_comments')) {
|
||||
$this->error('Target table `news_article_comments` is missing. Run migrations first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::getSchemaBuilder()->hasColumn('news_articles', 'legacy_news_id')) {
|
||||
$this->error('Column `news_articles.legacy_news_id` is missing. Run migrations first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] No data will be written.');
|
||||
}
|
||||
|
||||
$articleMap = DB::table('news_articles')
|
||||
->whereNotNull('legacy_news_id')
|
||||
->pluck('id', 'legacy_news_id')
|
||||
->mapWithKeys(fn ($articleId, $legacyId): array => [(int) $legacyId => (int) $articleId])
|
||||
->all();
|
||||
|
||||
$validUserIds = DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
$alreadyImported = DB::table('news_article_comments')
|
||||
->whereNotNull('legacy_id')
|
||||
->pluck('legacy_id')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
$total = DB::connection('legacy')->table($legacyTable)->count();
|
||||
if ($total === 0) {
|
||||
$this->warn('No legacy news comments found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'imported' => 0,
|
||||
'skipped_duplicate' => 0,
|
||||
'skipped_article' => 0,
|
||||
'skipped_empty' => 0,
|
||||
'users_unmapped' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
$touchedArticleIds = [];
|
||||
|
||||
DB::connection('legacy')
|
||||
->table($legacyTable)
|
||||
->orderBy('comment_id')
|
||||
->chunk($chunk, function ($rows) use (&$alreadyImported, $articleMap, $validUserIds, $dryRun, $skipEmpty, &$stats, &$touchedArticleIds): void {
|
||||
$inserts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) ($row->comment_id ?? 0);
|
||||
$legacyNewsId = (int) ($row->news_id ?? 0);
|
||||
$legacyUserId = (int) ($row->user_id ?? 0);
|
||||
$body = trim((string) ($row->message ?? ''));
|
||||
|
||||
if ($legacyId < 1) {
|
||||
$stats['errors']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($alreadyImported[$legacyId])) {
|
||||
$stats['skipped_duplicate']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($body === '') {
|
||||
if ($skipEmpty) {
|
||||
$stats['skipped_empty']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = '[no content]';
|
||||
}
|
||||
|
||||
$articleId = $articleMap[$legacyNewsId] ?? null;
|
||||
if (! $articleId) {
|
||||
$stats['skipped_article']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedUserId = isset($validUserIds[$legacyUserId]) ? $legacyUserId : null;
|
||||
if ($resolvedUserId === null && $legacyUserId > 0) {
|
||||
$stats['users_unmapped']++;
|
||||
}
|
||||
|
||||
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
|
||||
$authorName = trim((string) ($row->author ?? ''));
|
||||
|
||||
$record = [
|
||||
'legacy_id' => $legacyId,
|
||||
'legacy_user_id' => $legacyUserId > 0 ? $legacyUserId : null,
|
||||
'article_id' => $articleId,
|
||||
'user_id' => $resolvedUserId,
|
||||
'parent_id' => null,
|
||||
'author_name' => $authorName !== '' ? $authorName : null,
|
||||
'body' => $body,
|
||||
'rendered_body' => nl2br(e($body)),
|
||||
'status' => 'visible',
|
||||
'legacy_posted_at' => $timestamp,
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
'deleted_at' => null,
|
||||
];
|
||||
|
||||
if (! $dryRun) {
|
||||
$inserts[] = $record;
|
||||
$alreadyImported[$legacyId] = true;
|
||||
$touchedArticleIds[$articleId] = $articleId;
|
||||
}
|
||||
|
||||
$stats['imported']++;
|
||||
}
|
||||
|
||||
if (! $dryRun && $inserts !== []) {
|
||||
try {
|
||||
DB::table('news_article_comments')->insert($inserts);
|
||||
} catch (\Throwable) {
|
||||
foreach ($inserts as $insert) {
|
||||
try {
|
||||
DB::table('news_article_comments')->insertOrIgnore([$insert]);
|
||||
} catch (\Throwable) {
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (! $dryRun && $touchedArticleIds !== []) {
|
||||
DB::table('news_articles')
|
||||
->whereIn('id', array_values($touchedArticleIds))
|
||||
->update(['comments_enabled' => true]);
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Result', 'Count'],
|
||||
[
|
||||
['Imported', $stats['imported']],
|
||||
['Skipped - already imported', $stats['skipped_duplicate']],
|
||||
['Skipped - article missing', $stats['skipped_article']],
|
||||
['Skipped - empty body', $stats['skipped_empty']],
|
||||
['Imported with unmapped user', $stats['users_unmapped']],
|
||||
['Errors', $stats['errors']],
|
||||
]
|
||||
);
|
||||
|
||||
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveLegacyTable(): ?string
|
||||
{
|
||||
$configured = trim((string) $this->option('table'));
|
||||
if ($configured !== '') {
|
||||
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
|
||||
}
|
||||
|
||||
foreach (['news_comment', 'news_comments'] as $candidate) {
|
||||
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeTimestamp(mixed $value): string
|
||||
{
|
||||
$raw = trim((string) ($value ?? ''));
|
||||
|
||||
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
|
||||
return now()->toDateTimeString();
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return now()->toDateTimeString();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/Console/Commands/InspectArtworkOriginalCommand.php
Normal file
77
app/Console/Commands/InspectArtworkOriginalCommand.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
final class InspectArtworkOriginalCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:inspect-original
|
||||
{--artwork-id= : Artwork ID to inspect}
|
||||
{--id= : Legacy alias for artwork ID}';
|
||||
|
||||
protected $description = 'Show which original artwork file path resolves for an artwork and print the output URLs.';
|
||||
|
||||
public function handle(ArtworkOriginalFileLocator $locator): int
|
||||
{
|
||||
$artworkId = $this->resolveArtworkIdOption();
|
||||
if ($artworkId === null) {
|
||||
$this->error('Provide --artwork-id=ID.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'slug', 'file_name', 'file_path', 'hash', 'file_ext'])
|
||||
->find($artworkId);
|
||||
|
||||
if (! $artwork) {
|
||||
$this->error(sprintf('Artwork %d not found.', $artworkId));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$localPath = $locator->resolveLocalPath($artwork);
|
||||
$objectPath = $locator->resolveObjectPath($artwork);
|
||||
$objectUrl = $locator->resolveObjectUrl($artwork);
|
||||
$downloadUrl = route('art.download', ['id' => (int) $artwork->id]);
|
||||
$artworkUrl = route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
]);
|
||||
|
||||
$this->line('artwork_id: ' . (string) $artwork->id);
|
||||
$this->line('file_name: ' . (string) ($artwork->file_name ?? ''));
|
||||
$this->line('file_ext: ' . (string) ($artwork->file_ext ?? ''));
|
||||
$this->line('stored_file_path: ' . (string) ($artwork->file_path ?? ''));
|
||||
$this->line('source_file: ' . ($localPath !== '' ? $localPath : '(unresolved local path)'));
|
||||
$this->line('source_file_exists: ' . (File::isFile($localPath) ? 'yes' : 'no'));
|
||||
$this->line('source_object: ' . ($objectPath !== '' ? $objectPath : '(unresolved object path)'));
|
||||
$this->line('output_url: ' . ($objectUrl !== null && $objectUrl !== '' ? $objectUrl : '(unresolved object url)'));
|
||||
$this->line('download_url: ' . $downloadUrl);
|
||||
$this->line('artwork_url: ' . $artworkUrl);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveArtworkIdOption(): ?int
|
||||
{
|
||||
$artworkId = $this->option('artwork-id');
|
||||
if ($artworkId !== null) {
|
||||
return max(1, (int) $artworkId);
|
||||
}
|
||||
|
||||
$legacyId = $this->option('id');
|
||||
if ($legacyId !== null) {
|
||||
return max(1, (int) $legacyId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
305
app/Console/Commands/InspectArtworkSearchIndexCommand.php
Normal file
305
app/Console/Commands/InspectArtworkSearchIndexCommand.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
final class InspectArtworkSearchIndexCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-inspect
|
||||
{artwork_id? : The artwork ID to inspect}
|
||||
{--index= : Override the Meilisearch index name}
|
||||
{--generated-only : Only print the locally generated search document}
|
||||
{--live-only : Only print the live document fetched from Meilisearch}
|
||||
{--json : Print the inspection payload as raw JSON}';
|
||||
|
||||
protected $description = 'Inspect the generated Scout payload and live Meilisearch document for a single artwork.';
|
||||
|
||||
public function handle(MeilisearchClient $client): int
|
||||
{
|
||||
if ($this->option('generated-only') && $this->option('live-only')) {
|
||||
$this->error('Use either --generated-only or --live-only, not both together.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$artworkId = $this->resolveArtworkId();
|
||||
|
||||
if ($artworkId === null) {
|
||||
$this->error('An artwork ID is required.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()
|
||||
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
|
||||
->find($artworkId);
|
||||
|
||||
if ($artwork === null && ! $this->option('live-only')) {
|
||||
$this->error("Artwork #{$artworkId} was not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$indexName = $this->resolveIndexName($artwork);
|
||||
$inspection = [
|
||||
'artwork_id' => $artworkId,
|
||||
'index' => $indexName,
|
||||
'queue_runtime' => $this->queueRuntimeSummary(),
|
||||
'artwork' => $artwork ? $this->artworkSummary($artwork) : null,
|
||||
'generated_document' => null,
|
||||
'live_document' => null,
|
||||
'documents_match' => null,
|
||||
'live_fetch_error' => null,
|
||||
'diagnosis' => [],
|
||||
];
|
||||
|
||||
if (! $this->option('live-only') && $artwork !== null) {
|
||||
$inspection['generated_document'] = $artwork->toSearchableArray();
|
||||
}
|
||||
|
||||
if (! $this->option('generated-only')) {
|
||||
try {
|
||||
$inspection['live_document'] = $client->index($indexName)->getDocument($artworkId);
|
||||
} catch (\Throwable $exception) {
|
||||
$inspection['live_fetch_error'] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($inspection['generated_document']) && is_array($inspection['live_document'])) {
|
||||
$inspection['documents_match'] = $this->normalizeForComparison($inspection['generated_document'])
|
||||
=== $this->normalizeForComparison($inspection['live_document']);
|
||||
}
|
||||
|
||||
$inspection['diagnosis'] = $this->buildDiagnosis($artwork, $inspection);
|
||||
|
||||
$this->renderInspection($inspection);
|
||||
|
||||
if ($inspection['generated_document'] === null && $inspection['live_document'] === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveArtworkId(): ?int
|
||||
{
|
||||
$argument = $this->argument('artwork_id');
|
||||
|
||||
if ($argument !== null && $argument !== '') {
|
||||
return max(1, (int) $argument);
|
||||
}
|
||||
|
||||
if (! $this->input->isInteractive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$answer = $this->ask('Artwork ID');
|
||||
|
||||
if ($answer === null || trim($answer) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(1, (int) $answer);
|
||||
}
|
||||
|
||||
private function resolveIndexName(?Artwork $artwork): string
|
||||
{
|
||||
$override = trim((string) $this->option('index'));
|
||||
|
||||
if ($override !== '') {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if ($artwork !== null) {
|
||||
return $artwork->searchableAs();
|
||||
}
|
||||
|
||||
return (string) config('scout.prefix', '') . 'artworks';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function artworkSummary(Artwork $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($artwork->title ?? ''),
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'should_be_indexed' => (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null),
|
||||
'searchable_index' => $artwork->searchableAs(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function queueRuntimeSummary(): array
|
||||
{
|
||||
return [
|
||||
'queue_default_connection' => (string) config('queue.default', 'sync'),
|
||||
'scout_queue_connection' => (string) config('scout.queue.connection', (string) config('queue.default', 'sync')),
|
||||
'scout_queue_name' => (string) config('scout.queue.queue', 'default'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inspection
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildDiagnosis(?Artwork $artwork, array $inspection): array
|
||||
{
|
||||
$messages = [];
|
||||
$queueDefault = (string) data_get($inspection, 'queue_runtime.queue_default_connection', 'sync');
|
||||
$scoutQueueConnection = (string) data_get($inspection, 'queue_runtime.scout_queue_connection', $queueDefault);
|
||||
$scoutQueueName = (string) data_get($inspection, 'queue_runtime.scout_queue_name', 'default');
|
||||
|
||||
if ($artwork === null) {
|
||||
$messages[] = 'Artwork row was not found locally, so only a direct live-index check was possible.';
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
$shouldBeIndexed = (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null);
|
||||
|
||||
if (! $shouldBeIndexed) {
|
||||
$messages[] = 'This artwork should not exist in Meilisearch right now because it is not simultaneously public, approved, and published.';
|
||||
}
|
||||
|
||||
if (is_string($inspection['live_fetch_error'] ?? null) && str_contains(strtolower((string) $inspection['live_fetch_error']), 'not found')) {
|
||||
$messages[] = 'The live Meilisearch document is missing from the inspected index.';
|
||||
|
||||
if ($shouldBeIndexed) {
|
||||
$messages[] = 'That usually means one of three things: the artwork has not been indexed yet, the Scout sync worker has not processed the job, or you are inspecting the wrong index name/prefix.';
|
||||
|
||||
if ($scoutQueueConnection !== $queueDefault) {
|
||||
$messages[] = sprintf(
|
||||
'This runtime is using queue.default=%s but Scout sync uses scout.queue.connection=%s on queue=%s. If workers only consume %s, Meilisearch updates will never be processed.',
|
||||
$queueDefault,
|
||||
$scoutQueueConnection,
|
||||
$scoutQueueName,
|
||||
$queueDefault,
|
||||
);
|
||||
|
||||
if ($scoutQueueConnection === 'database') {
|
||||
$messages[] = sprintf(
|
||||
'In this configuration, artwork indexing writes are likely sitting on the database queue. Either run a worker for that backend, for example: php artisan queue:work database --queue=%s, or align SCOUT_QUEUE_CONNECTION with your main queue backend.',
|
||||
$scoutQueueName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$messages[] = sprintf(
|
||||
'Scout is configured to use queue connection %s and queue name %s. Make sure at least one worker actively consumes that exact queue.',
|
||||
$scoutQueueConnection,
|
||||
$scoutQueueName,
|
||||
);
|
||||
}
|
||||
|
||||
$messages[] = 'If this artwork should be searchable now, requeue it with: php artisan artworks:search-reindex-recent or run a full rebuild with: php artisan artworks:search-rebuild';
|
||||
}
|
||||
}
|
||||
|
||||
if (($inspection['documents_match'] ?? null) === false) {
|
||||
$messages[] = 'The local generated document and the live Meilisearch document differ, so the live index is stale or from a different schema/version.';
|
||||
}
|
||||
|
||||
if ($messages === []) {
|
||||
$messages[] = 'No obvious indexing problem was detected from this inspection output.';
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inspection
|
||||
*/
|
||||
private function renderInspection(array $inspection): void
|
||||
{
|
||||
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES;
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$this->line((string) json_encode($inspection, $jsonFlags));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Artwork search inspect — artwork #%d, index %s',
|
||||
(int) $inspection['artwork_id'],
|
||||
(string) $inspection['index'],
|
||||
));
|
||||
$this->newLine();
|
||||
|
||||
if (is_array($inspection['artwork'])) {
|
||||
$this->comment('Artwork');
|
||||
$this->line((string) json_encode($inspection['artwork'], $jsonFlags));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (is_array($inspection['queue_runtime'])) {
|
||||
$this->comment('Queue runtime');
|
||||
$this->line((string) json_encode($inspection['queue_runtime'], $jsonFlags));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($inspection['documents_match'] !== null) {
|
||||
$this->line('Generated/live document match: ' . ($inspection['documents_match'] ? 'yes' : 'no'));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (is_array($inspection['diagnosis']) && $inspection['diagnosis'] !== []) {
|
||||
$this->comment('Diagnosis');
|
||||
foreach ($inspection['diagnosis'] as $message) {
|
||||
$this->line('- ' . $message);
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (is_array($inspection['generated_document'])) {
|
||||
$this->comment('Generated search document');
|
||||
$this->line((string) json_encode($inspection['generated_document'], $jsonFlags));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (is_array($inspection['live_document'])) {
|
||||
$this->comment('Live Meilisearch document');
|
||||
$this->line((string) json_encode($inspection['live_document'], $jsonFlags));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (is_string($inspection['live_fetch_error']) && $inspection['live_fetch_error'] !== '') {
|
||||
$this->warn('Live document fetch failed: ' . $inspection['live_fetch_error']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return mixed
|
||||
*/
|
||||
private function normalizeForComparison(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->normalizeForComparison($item);
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
ksort($value);
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ final class PublishSitemapsCommand extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$this->line('<fg=cyan>Building sitemap release...</>');
|
||||
|
||||
try {
|
||||
$manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -36,11 +39,59 @@ final class PublishSitemapsCommand extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$elapsed = microtime(true) - $startedAt;
|
||||
|
||||
// Per-family table (shown with -v or higher)
|
||||
if ($this->output->isVerbose()) {
|
||||
$rows = [];
|
||||
foreach ((array) data_get($manifest, 'families', []) as $family => $info) {
|
||||
$rows[] = [
|
||||
$family,
|
||||
(int) data_get($info, 'url_count', 0),
|
||||
(int) data_get($info, 'shard_count', 0),
|
||||
count((array) data_get($info, 'documents', [])),
|
||||
(string) data_get($info, 'type', 'urlset'),
|
||||
];
|
||||
}
|
||||
$this->table(['Family', 'URLs', 'Shards', 'Docs', 'Type'], $rows);
|
||||
}
|
||||
|
||||
// Validation detail (shown with -vv or higher)
|
||||
if ($this->output->isVeryVerbose()) {
|
||||
$validation = (array) data_get($manifest, 'validation', []);
|
||||
$checks = (array) data_get($validation, 'checks', []);
|
||||
if ($checks !== []) {
|
||||
$this->line('<fg=yellow>Validation checks:</>');
|
||||
$checkRows = [];
|
||||
foreach ($checks as $check => $result) {
|
||||
$ok = (bool) data_get($result, 'ok', true);
|
||||
$checkRows[] = [
|
||||
$check,
|
||||
$ok ? '<fg=green>OK</>' : '<fg=red>FAIL</>',
|
||||
(string) data_get($result, 'message', ''),
|
||||
];
|
||||
}
|
||||
$this->table(['Check', 'Status', 'Message'], $checkRows);
|
||||
}
|
||||
}
|
||||
|
||||
// Static publish result
|
||||
$staticResult = (array) data_get($manifest, 'static_published', []);
|
||||
if ($staticResult !== [] && $this->output->isVerbose()) {
|
||||
$this->line(sprintf(
|
||||
'<fg=cyan>Static files written to public/:</> written=%d skipped=%d',
|
||||
(int) data_get($staticResult, 'written', 0),
|
||||
(int) data_get($staticResult, 'skipped', 0),
|
||||
));
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Published sitemap release [%s] with %d families and %d documents.',
|
||||
'Published sitemap release [%s] — %d families, %d documents, %d URLs (%.2fs)',
|
||||
(string) $manifest['release_id'],
|
||||
(int) data_get($manifest, 'totals.families', 0),
|
||||
(int) data_get($manifest, 'totals.documents', 0),
|
||||
(int) data_get($manifest, 'totals.urls', 0),
|
||||
$elapsed,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@@ -5,26 +5,205 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
class RebuildArtworkSearchIndex extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
|
||||
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
|
||||
protected $signature = 'artworks:search-rebuild
|
||||
{--chunk=500 : Number of artworks per chunk}
|
||||
{--limit= : Stop after processing this many artworks (useful for testing)}
|
||||
{--reverse : Process artworks newest-first (highest ID first)}
|
||||
{--sync : Write directly to Meilisearch (no queue) and show per-artwork results}';
|
||||
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based). Use --sync for verbose direct writes.';
|
||||
|
||||
public function __construct(private readonly ArtworkSearchIndexer $indexer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
public function handle(MeilisearchClient $client): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$reverse = (bool) $this->option('reverse');
|
||||
$sync = (bool) $this->option('sync');
|
||||
|
||||
$this->info("Dispatching index jobs in chunks of {$chunk}…");
|
||||
$this->indexer->rebuildAll($chunk);
|
||||
$this->info('All jobs dispatched. Workers will process them asynchronously.');
|
||||
if ($sync) {
|
||||
return $this->handleSync($client, $chunk, $limit, $reverse);
|
||||
}
|
||||
|
||||
return $this->handleQueue($chunk, $limit, $reverse);
|
||||
}
|
||||
|
||||
// ── Queue mode (default) ──────────────────────────────────────────────────
|
||||
|
||||
private function handleQueue(int $chunk, ?int $limit, bool $reverse): int
|
||||
{
|
||||
$uncapped = Artwork::query()->public()->published()->count();
|
||||
$total = $limit !== null ? min($limit, $uncapped) : $uncapped;
|
||||
|
||||
if ($total === 0) {
|
||||
$this->warn('No public, published artworks matched the rebuild query. Nothing was queued.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$estimatedChunks = (int) ceil($total / $chunk);
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queueing Meilisearch rebuild for %d artwork(s) in %d chunk(s) of up to %d%s%s.',
|
||||
$total,
|
||||
$estimatedChunks,
|
||||
$chunk,
|
||||
$reverse ? ', newest first' : '',
|
||||
$limit !== null ? " (limit {$limit})" : '',
|
||||
));
|
||||
$this->line('This command only dispatches queue jobs. Workers process the actual indexing asynchronously.');
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
|
||||
$bar->start();
|
||||
|
||||
$startedAt = microtime(true);
|
||||
|
||||
$stats = $this->indexer->rebuildAll(
|
||||
$chunk,
|
||||
function (int $chunkNumber, int $chunkCount, int $dispatched, int $totalItems, int $firstId, int $lastId) use ($bar): void {
|
||||
|
||||
$bar->advance($chunkCount);
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$bar->clear();
|
||||
$this->line(sprintf(
|
||||
'Chunk %d queued %d artwork(s) [ids %d-%d] (%d/%d dispatched).',
|
||||
$chunkNumber,
|
||||
$chunkCount,
|
||||
$firstId,
|
||||
$lastId,
|
||||
$dispatched,
|
||||
$totalItems,
|
||||
));
|
||||
$bar->display();
|
||||
}
|
||||
},
|
||||
$reverse,
|
||||
$limit,
|
||||
);
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$elapsed = microtime(true) - $startedAt;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d artwork(s) across %d chunk(s) in %.2f seconds.',
|
||||
$stats['dispatched'],
|
||||
$stats['chunks'],
|
||||
$elapsed,
|
||||
));
|
||||
$this->line('Workers will process the actual Meilisearch writes asynchronously.');
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line('Tip: use -v for per-chunk output, or monitor Horizon/queue workers for completion.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Sync mode (--sync) ────────────────────────────────────────────────────
|
||||
|
||||
private function handleSync(MeilisearchClient $client, int $chunk, ?int $limit, bool $reverse): int
|
||||
{
|
||||
$this->info(sprintf(
|
||||
'<options=bold>[SYNC MODE]</> Writing directly to Meilisearch%s%s — no queue involved.',
|
||||
$reverse ? ', newest first' : '',
|
||||
$limit !== null ? ", limit {$limit}" : '',
|
||||
));
|
||||
$this->newLine();
|
||||
|
||||
$query = Artwork::with([
|
||||
'user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat',
|
||||
])
|
||||
->withoutGlobalScopes() // include non-public so we can report "why not"
|
||||
->whereNotNull('id'); // all artworks
|
||||
|
||||
if ($reverse) {
|
||||
$query->orderByDesc('id');
|
||||
} else {
|
||||
$query->orderBy('id');
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$total = (clone $query)->count();
|
||||
$indexed = 0;
|
||||
$removed = 0;
|
||||
$failed = 0;
|
||||
$processed = 0;
|
||||
$startedAt = microtime(true);
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
|
||||
$bar->start();
|
||||
|
||||
$query->chunk($chunk, function ($artworks) use ($client, $bar, &$indexed, &$removed, &$failed, &$processed): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
$processed++;
|
||||
$id = (int) $artwork->id;
|
||||
$title = (string) ($artwork->title ?? '(no title)');
|
||||
|
||||
// Determine eligibility and reason
|
||||
$reasons = [];
|
||||
if (! $artwork->is_public) { $reasons[] = 'not public'; }
|
||||
if (! $artwork->is_approved) { $reasons[] = 'not approved'; }
|
||||
if ($artwork->published_at === null) { $reasons[] = 'not published'; }
|
||||
if ($artwork->deleted_at !== null) { $reasons[] = 'soft-deleted'; }
|
||||
|
||||
$eligible = empty($reasons);
|
||||
|
||||
try {
|
||||
$indexName = $artwork->searchableAs();
|
||||
|
||||
if ($eligible) {
|
||||
$document = $artwork->toSearchableArray();
|
||||
$client->index($indexName)->addDocuments([$document]);
|
||||
$indexed++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <info>✓ indexed</info> #%d "%s"', $id, $title));
|
||||
} else {
|
||||
$client->index($indexName)->deleteDocument($id);
|
||||
$removed++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <comment>– removed</comment> #%d "%s" [%s]', $id, $title, implode(', ', $reasons)));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <error>✗ failed</error> #%d "%s" %s', $id, $title, $e->getMessage()));
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$elapsed = microtime(true) - $startedAt;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Done in %.2f s — %d indexed, %d removed from index, %d failed (of %d processed).',
|
||||
$elapsed,
|
||||
$indexed,
|
||||
$removed,
|
||||
$failed,
|
||||
$processed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
279
app/Console/Commands/ReconcileArtworkSearchIndexCommand.php
Normal file
279
app/Console/Commands/ReconcileArtworkSearchIndexCommand.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\DeleteArtworkFromIndexJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
final class ReconcileArtworkSearchIndexCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-reconcile
|
||||
{--id=* : Specific artwork IDs to inspect instead of scanning the full catalog}
|
||||
{--after-id=0 : Resume scanning after this artwork id in the chosen sort direction}
|
||||
{--chunk=200 : Number of artworks per chunk}
|
||||
{--limit=0 : Stop after this many artworks (0 = no limit)}
|
||||
{--recent-minutes=0 : Only inspect artworks touched recently by created_at, updated_at, or published_at}
|
||||
{--reverse : Process highest artwork ids first}
|
||||
{--repair : Apply fixes instead of reporting only}
|
||||
{--queue : When repairing, dispatch queue jobs instead of writing directly to Meilisearch}
|
||||
{--remove-unexpected : Remove live documents for artworks that should not be indexed}
|
||||
{--no-cache-bump : Skip bumping explore cache version after repairs}}';
|
||||
|
||||
protected $description = 'Audit the artwork Meilisearch index against the database and repair missing or stale documents.';
|
||||
|
||||
public function handle(MeilisearchClient $client): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$recentMinutes = max(0, (int) $this->option('recent-minutes'));
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
|
||||
$reverse = (bool) $this->option('reverse');
|
||||
$repair = (bool) $this->option('repair');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$removeUnexpected = (bool) $this->option('remove-unexpected');
|
||||
$bumpCache = ! (bool) $this->option('no-cache-bump');
|
||||
|
||||
if ($queue && ! $repair) {
|
||||
$this->error('The --queue option requires --repair.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$query = Artwork::query()
|
||||
->withoutGlobalScopes()
|
||||
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
|
||||
->when($afterId > 0, function ($builder) use ($afterId, $reverse): void {
|
||||
$builder->where('id', $reverse ? '<' : '>', $afterId);
|
||||
})
|
||||
->orderBy('id', $reverse ? 'desc' : 'asc');
|
||||
|
||||
if ($ids === [] && $recentMinutes > 0) {
|
||||
$cutoff = Carbon::now()->subMinutes($recentMinutes);
|
||||
|
||||
$query->where(function ($builder) use ($cutoff): void {
|
||||
$builder->where('created_at', '>=', $cutoff)
|
||||
->orWhere('updated_at', '>=', $cutoff)
|
||||
->orWhere('published_at', '>=', $cutoff);
|
||||
});
|
||||
}
|
||||
|
||||
if ($ids !== []) {
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
$uncappedTotal = (clone $query)->count();
|
||||
|
||||
if ($limit > 0) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$total = $limit > 0 ? min($limit, $uncappedTotal) : $uncappedTotal;
|
||||
|
||||
if ($total === 0) {
|
||||
$this->warn('No artworks matched the reconcile query.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%sReconciling %d artwork(s)%s%s%s%s.',
|
||||
$repair ? '[REPAIR] ' : '[REPORT] ',
|
||||
$total,
|
||||
$ids !== [] ? ' for selected ids' : '',
|
||||
$recentMinutes > 0 && $ids === [] ? sprintf(' touched in the last %d minute(s)', $recentMinutes) : '',
|
||||
$reverse ? ' newest first' : '',
|
||||
$queue ? ' using queued repair jobs' : '',
|
||||
));
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
|
||||
$bar->start();
|
||||
|
||||
$stats = [
|
||||
'processed' => 0,
|
||||
'ok' => 0,
|
||||
'missing' => 0,
|
||||
'stale' => 0,
|
||||
'unexpected' => 0,
|
||||
'repaired' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
|
||||
$indexName = null;
|
||||
|
||||
$chunkMethod = $reverse ? 'chunkByIdDesc' : 'chunkById';
|
||||
|
||||
$query->{$chunkMethod}($chunk, function ($artworks) use ($client, $repair, $queue, $removeUnexpected, $bar, &$stats, &$indexName): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
$stats['processed']++;
|
||||
|
||||
$indexName ??= $artwork->searchableAs();
|
||||
$artworkId = (int) $artwork->id;
|
||||
$eligible = $this->shouldBeIndexed($artwork);
|
||||
$generatedDocument = $eligible ? $artwork->toSearchableArray() : null;
|
||||
$liveDocument = null;
|
||||
$liveMissing = false;
|
||||
|
||||
try {
|
||||
$liveDocument = $client->index($indexName)->getDocument($artworkId);
|
||||
} catch (\Throwable $exception) {
|
||||
if ($this->isMissingDocumentError($exception)) {
|
||||
$liveMissing = true;
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <error>error</error> #%d %s', $artworkId, $exception->getMessage()));
|
||||
$bar->display();
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$status = 'ok';
|
||||
|
||||
if ($eligible) {
|
||||
if ($liveMissing) {
|
||||
$status = 'missing';
|
||||
$stats['missing']++;
|
||||
} elseif (! $this->documentsMatch($generatedDocument, $liveDocument)) {
|
||||
$status = 'stale';
|
||||
$stats['stale']++;
|
||||
} else {
|
||||
$stats['ok']++;
|
||||
}
|
||||
} else {
|
||||
if (! $liveMissing) {
|
||||
$status = 'unexpected';
|
||||
$stats['unexpected']++;
|
||||
} else {
|
||||
$stats['ok']++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 'ok') {
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <comment>%s</comment> #%d %s', $status, $artworkId, (string) ($artwork->slug ?? '')));
|
||||
$bar->display();
|
||||
}
|
||||
|
||||
if ($repair) {
|
||||
try {
|
||||
if (in_array($status, ['missing', 'stale'], true)) {
|
||||
$this->repairIndexDocument($client, $artwork, $generatedDocument ?? [], $queue);
|
||||
$stats['repaired']++;
|
||||
} elseif ($status === 'unexpected' && $removeUnexpected) {
|
||||
$this->repairUnexpectedDocument($client, $artworkId, $indexName, $queue);
|
||||
$stats['repaired']++;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$stats['failed']++;
|
||||
$bar->clear();
|
||||
$this->line(sprintf(' <error>repair failed</error> #%d %s', $artworkId, $exception->getMessage()));
|
||||
$bar->display();
|
||||
}
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
if ($repair && $stats['repaired'] > 0 && $bumpCache) {
|
||||
$newVersion = ((int) Cache::get('explore.cache.version', 1)) + 1;
|
||||
Cache::forever('explore.cache.version', $newVersion);
|
||||
$this->line("Explore cache version bumped to {$newVersion}.");
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['processed', 'ok', 'missing', 'stale', 'unexpected', 'repaired', 'failed'],
|
||||
[[
|
||||
$stats['processed'],
|
||||
$stats['ok'],
|
||||
$stats['missing'],
|
||||
$stats['stale'],
|
||||
$stats['unexpected'],
|
||||
$stats['repaired'],
|
||||
$stats['failed'],
|
||||
]]
|
||||
);
|
||||
|
||||
if (! $repair) {
|
||||
$this->line('Run again with --repair to fix missing/stale documents directly.');
|
||||
} elseif (! $removeUnexpected && $stats['unexpected'] > 0) {
|
||||
$this->line('Unexpected live documents were only reported. Re-run with --remove-unexpected to delete them.');
|
||||
}
|
||||
|
||||
return $stats['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function shouldBeIndexed(Artwork $artwork): bool
|
||||
{
|
||||
return (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null && $artwork->published_at->lte(Carbon::now()) && $artwork->deleted_at === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $generatedDocument
|
||||
* @param mixed $liveDocument
|
||||
*/
|
||||
private function documentsMatch(array $generatedDocument, mixed $liveDocument): bool
|
||||
{
|
||||
if (! is_array($liveDocument)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->normalizeForComparison($generatedDocument) === $this->normalizeForComparison($liveDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
*/
|
||||
private function repairIndexDocument(MeilisearchClient $client, Artwork $artwork, array $document, bool $queue): void
|
||||
{
|
||||
if ($queue) {
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$client->index($artwork->searchableAs())->addDocuments([$document]);
|
||||
}
|
||||
|
||||
private function repairUnexpectedDocument(MeilisearchClient $client, int $artworkId, string $indexName, bool $queue): void
|
||||
{
|
||||
if ($queue) {
|
||||
DeleteArtworkFromIndexJob::dispatch($artworkId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$client->index($indexName)->deleteDocument($artworkId);
|
||||
}
|
||||
|
||||
private function isMissingDocumentError(\Throwable $exception): bool
|
||||
{
|
||||
return str_contains(strtolower($exception->getMessage()), 'not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeForComparison(array $document): array
|
||||
{
|
||||
$normalized = Arr::sortRecursive($document);
|
||||
unset($normalized['_formatted']);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/RefreshLeaderboardsCommand.php
Normal file
34
app/Console/Commands/RefreshLeaderboardsCommand.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshLeaderboardsCommand extends Command
|
||||
{
|
||||
protected $signature = 'leaderboards:refresh';
|
||||
|
||||
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
|
||||
|
||||
public function __construct(private readonly LeaderboardService $leaderboards)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Refreshing leaderboards …');
|
||||
|
||||
$results = $this->leaderboards->refreshAll();
|
||||
$updated = collect($results)
|
||||
->flatten(1)
|
||||
->sum(fn (int $count): int => $count);
|
||||
|
||||
$this->info("Done. Updated: {$updated} leaderboard row(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
@@ -0,0 +1,502 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
final class RepairArtworkThumbnailsCommand extends Command
|
||||
{
|
||||
private const SOURCE_IMAGE_EXTENSIONS = [
|
||||
'avif',
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'tif',
|
||||
'tiff',
|
||||
'webp',
|
||||
];
|
||||
|
||||
protected $signature = 'artworks:repair-missing-thumbnails
|
||||
{--id= : Repair only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=200 : Number of artworks to scan per batch}
|
||||
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
|
||||
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
|
||||
{--csv= : Optional path to write a CSV report}
|
||||
{--force : Regenerate the selected variants even when they already exist}
|
||||
{--dry-run : Report repairs without writing files}';
|
||||
|
||||
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
|
||||
|
||||
public function handle(
|
||||
UploadStorageService $storage,
|
||||
UploadDerivativesService $derivatives,
|
||||
ArtworkFileRepository $artworkFiles,
|
||||
ArtworkOriginalFileLocator $locator,
|
||||
ArtworkCdnPurgeService $cdnPurge,
|
||||
): int {
|
||||
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
|
||||
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
|
||||
$csvPath = trim((string) $this->option('csv'));
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$allVariants = $this->resolveConfiguredVariants();
|
||||
$selectedVariants = $this->resolveSelectedVariants($allVariants);
|
||||
if ($selectedVariants === []) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$auditColumnsAvailable = Schema::hasColumns('artworks', [
|
||||
'has_missing_thumbnails',
|
||||
'missing_thumbnail_variants_json',
|
||||
'thumbnails_checked_at',
|
||||
]);
|
||||
|
||||
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
|
||||
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$diskName = $storage->objectDiskName();
|
||||
$disk = Storage::disk($diskName);
|
||||
$csvHandle = $this->openCsvHandle($csvPath);
|
||||
|
||||
$baseQuery = $this->baseQuery($onlyMissingFlagged);
|
||||
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
|
||||
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
|
||||
$diskName,
|
||||
implode(',', $selectedVariants),
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$onlyMissingFlagged ? 'yes' : 'no',
|
||||
$force ? 'yes' : 'no',
|
||||
$dryRun ? 'yes' : 'no',
|
||||
$csvPath !== '' ? $csvPath : 'off',
|
||||
));
|
||||
|
||||
if ($progressBar !== null) {
|
||||
$progressBar->start();
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$healthy = 0;
|
||||
$planned = 0;
|
||||
$repaired = 0;
|
||||
$failed = 0;
|
||||
$lastSeenId = null;
|
||||
|
||||
try {
|
||||
do {
|
||||
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
|
||||
if ($artworks->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
try {
|
||||
$targetVariants = $force
|
||||
? $selectedVariants
|
||||
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
|
||||
|
||||
if ($targetVariants === []) {
|
||||
$healthy++;
|
||||
$processed++;
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'healthy',
|
||||
'variants' => '',
|
||||
'source_file' => '',
|
||||
'message' => '',
|
||||
]);
|
||||
$progressBar?->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
|
||||
if ($sourcePath === '') {
|
||||
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$planned++;
|
||||
$this->line(sprintf(
|
||||
'Artwork %d would repair thumbnails: %s',
|
||||
(int) $artwork->id,
|
||||
implode(',', $targetVariants),
|
||||
));
|
||||
$this->line(' source_file: ' . $sourcePath);
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'planned',
|
||||
'variants' => implode(',', $targetVariants),
|
||||
'source_file' => $sourcePath,
|
||||
'message' => '',
|
||||
]);
|
||||
$processed++;
|
||||
$progressBar?->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
|
||||
if ($assets === []) {
|
||||
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
|
||||
foreach ($assets as $variant => $asset) {
|
||||
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
|
||||
}
|
||||
|
||||
$update = [
|
||||
'thumb_ext' => 'webp',
|
||||
];
|
||||
|
||||
if ($auditColumnsAvailable) {
|
||||
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
|
||||
$update['has_missing_thumbnails'] = $remainingMissing !== [];
|
||||
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
|
||||
? null
|
||||
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
|
||||
$update['thumbnails_checked_at'] = now();
|
||||
}
|
||||
|
||||
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
|
||||
});
|
||||
|
||||
$cdnPurge->purgeArtworkObjectPaths(array_map(
|
||||
static fn (array $asset): string => (string) $asset['path'],
|
||||
array_values($assets),
|
||||
), [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'reason' => 'thumbnail_repair',
|
||||
]);
|
||||
|
||||
$repaired++;
|
||||
$this->info(sprintf(
|
||||
'Artwork %d repaired thumbnails: %s',
|
||||
(int) $artwork->id,
|
||||
implode(',', array_keys($assets)),
|
||||
));
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'repaired',
|
||||
'variants' => implode(',', array_keys($assets)),
|
||||
'source_file' => $sourcePath,
|
||||
'message' => '',
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
$this->writeCsvRow($csvHandle, [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'failed',
|
||||
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
|
||||
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$progressBar?->advance();
|
||||
}
|
||||
|
||||
$lastSeenId = (int) $artworks->last()->id;
|
||||
} while (true);
|
||||
} finally {
|
||||
if ($progressBar !== null) {
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
}
|
||||
|
||||
if (is_resource($csvHandle)) {
|
||||
fclose($csvHandle);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
|
||||
$processed,
|
||||
$healthy,
|
||||
$planned,
|
||||
$repaired,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
|
||||
{
|
||||
$query = clone $baseQuery;
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
} elseif ($lastSeenId !== null) {
|
||||
$query->where('id', '<', $lastSeenId);
|
||||
}
|
||||
|
||||
return $query->limit($chunkSize)->get();
|
||||
}
|
||||
|
||||
private function baseQuery(bool $onlyMissingFlagged): mixed
|
||||
{
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
|
||||
->whereNotNull('hash')
|
||||
->where('hash', '!=', '')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($onlyMissingFlagged) {
|
||||
$query->where('has_missing_thumbnails', true);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
|
||||
{
|
||||
$countQuery = clone $baseQuery;
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$countQuery->whereKey($artworkId);
|
||||
}
|
||||
|
||||
$count = (int) $countQuery->count();
|
||||
if ($limit !== null) {
|
||||
return min($count, $limit);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveConfiguredVariants(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||
array_keys((array) config('uploads.derivatives', [])),
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $configuredVariants
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveSelectedVariants(array $configuredVariants): array
|
||||
{
|
||||
if ($configuredVariants === []) {
|
||||
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$requested = (array) $this->option('variant');
|
||||
if ($requested === []) {
|
||||
return $configuredVariants;
|
||||
}
|
||||
|
||||
$normalizedRequested = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($variant): string => strtolower(trim((string) $variant)),
|
||||
$requested,
|
||||
))));
|
||||
|
||||
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
|
||||
if ($invalid !== []) {
|
||||
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
|
||||
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $normalizedRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $variants
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
|
||||
{
|
||||
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||
if ($hash === '') {
|
||||
return $variants;
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach ($variants as $variant) {
|
||||
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
|
||||
if (! $disk->exists($objectPath)) {
|
||||
$missing[] = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
|
||||
{
|
||||
$hash = strtolower((string) ($artwork->hash ?? ''));
|
||||
if (! $this->isValidHash($hash)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$preferred = $locator->resolveLocalPath($artwork);
|
||||
if ($this->isUsableSourceFile($preferred)) {
|
||||
return $preferred;
|
||||
}
|
||||
|
||||
foreach ($this->candidateOriginalRoots() as $root) {
|
||||
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
|
||||
if ($candidatePath !== '') {
|
||||
return $candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function candidateOriginalRoots(): array
|
||||
{
|
||||
$roots = [
|
||||
trim((string) config('uploads.local_originals_root', '')),
|
||||
trim((string) config('uploads.readonly_backup_originals_root', '')),
|
||||
];
|
||||
|
||||
$normalizedRoots = [];
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if ($root === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoots[] = $normalizedRoot;
|
||||
}
|
||||
|
||||
return $normalizedRoots;
|
||||
}
|
||||
|
||||
private function findNonZipSourceInRoot(string $root, string $hash): string
|
||||
{
|
||||
$directory = $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
|
||||
|
||||
if (! File::isDirectory($directory)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
|
||||
if (! is_array($matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if ($this->isUsableSourceFile($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function isUsableSourceFile(string $path): bool
|
||||
{
|
||||
if ($path === '' || ! File::isFile($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mime = strtolower((string) (File::mimeType($path) ?? ''));
|
||||
|
||||
return str_starts_with($mime, 'image/');
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource|null
|
||||
*/
|
||||
private function openCsvHandle(string $csvPath)
|
||||
{
|
||||
if ($csvPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($csvPath));
|
||||
$handle = fopen($csvPath, 'wb');
|
||||
if (! is_resource($handle)) {
|
||||
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
|
||||
}
|
||||
|
||||
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource|null $csvHandle
|
||||
* @param array<string, scalar|null> $row
|
||||
*/
|
||||
private function writeCsvRow($csvHandle, array $row): void
|
||||
{
|
||||
if (! is_resource($csvHandle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fputcsv($csvHandle, [
|
||||
$row['artwork_id'] ?? '',
|
||||
$row['status'] ?? '',
|
||||
$row['variants'] ?? '',
|
||||
$row['source_file'] ?? '',
|
||||
$row['message'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/SendTestMail.php
Normal file
40
app/Console/Commands/SendTestMail.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Mail\TestMail;
|
||||
|
||||
class SendTestMail extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'mail:send-test {email?} {--body=}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send a test email to the given address or MAIL_USERNAME';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email') ?? env('MAIL_USERNAME') ?? 'gregor@klevze.com';
|
||||
$body = $this->option('body') ?? "This is a test email sent by php artisan mail:send-test.";
|
||||
|
||||
try {
|
||||
Mail::to($email)->send(new TestMail($body));
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to send mail: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Test mail sent to {$email}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
162
app/Console/Commands/SendUserVerificationEmailCommand.php
Normal file
162
app/Console/Commands/SendUserVerificationEmailCommand.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Mail\RegistrationVerificationMail;
|
||||
use App\Models\EmailSendEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationEmailQuotaService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class SendUserVerificationEmailCommand extends Command
|
||||
{
|
||||
protected $signature = 'user:send-verification-email
|
||||
{userId : The user ID that should receive the verification email}
|
||||
{--now : Send immediately instead of queueing the existing verification job}
|
||||
{--force : Allow sending even if the user is already verified}';
|
||||
|
||||
protected $description = 'Send the registration verification email to a specific user ID.';
|
||||
|
||||
public function __construct(
|
||||
private readonly RegistrationVerificationTokenService $tokenService,
|
||||
private readonly RegistrationEmailQuotaService $quotaService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$userId = (int) $this->argument('userId');
|
||||
|
||||
if ($userId < 1) {
|
||||
$this->error('The user ID must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User {$userId} was not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
|
||||
if ($email === '') {
|
||||
$this->error("User {$userId} does not have an email address.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($user->email_verified_at !== null && ! $this->option('force')) {
|
||||
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$token = $this->tokenService->createForUser($userId);
|
||||
|
||||
$event = EmailSendEvent::query()->create([
|
||||
'type' => 'verify_email',
|
||||
'email' => $email,
|
||||
'ip' => null,
|
||||
'user_id' => $userId,
|
||||
'status' => $this->option('now') ? 'pending' : 'queued',
|
||||
'reason' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if ($this->option('now')) {
|
||||
return $this->sendNow($user, $event, $token);
|
||||
}
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: $userId,
|
||||
ip: null,
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
$this->info("Queued verification email for user {$userId} <{$email}>.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function sendNow(User $user, EmailSendEvent $event, string $token): int
|
||||
{
|
||||
if (! $this->acquireGlobalSendSlot()) {
|
||||
$this->updateEvent($event, 'blocked', 'rate_limited');
|
||||
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->quotaService->isExceeded()) {
|
||||
$this->updateEvent($event, 'blocked', 'quota');
|
||||
$this->error('The monthly registration email quota is exceeded.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->updateEvent($event, 'failed', 'send_error');
|
||||
$this->error('Failed to send the verification email: ' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->quotaService->incrementSentCount();
|
||||
$this->updateEvent($event, 'sent', null);
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
$this->info("Sent verification email to user {$user->id} <{$email}>.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function acquireGlobalSendSlot(): bool
|
||||
{
|
||||
$key = 'registration:verification-email:global';
|
||||
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
|
||||
|
||||
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
|
||||
}
|
||||
|
||||
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
|
||||
{
|
||||
EmailSendEvent::query()
|
||||
->whereKey($event->getKey())
|
||||
->update([
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
private function markVerificationEmailSent(User $user): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$windowStartedAt = $user->verification_send_window_started_at;
|
||||
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
|
||||
$user->verification_send_window_started_at = $now;
|
||||
$user->verification_send_count_24h = 1;
|
||||
} else {
|
||||
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
|
||||
}
|
||||
|
||||
$user->last_verification_sent_at = $now;
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
714
app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php
Normal file
714
app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php
Normal file
@@ -0,0 +1,714 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
final class ZipUnsupportedArtworkOriginalsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:zip-unsupported-originals
|
||||
{--artwork-id= : Process only this artwork ID}
|
||||
{--id= : Process only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=200 : Number of artworks to scan per batch}
|
||||
{--force : Rebuild the zip even when the artwork currently points at a supported extension or an existing zip}
|
||||
{--delete-original-object : Delete the previous original object from object storage after repointing the artwork}
|
||||
{--dry-run : Report candidate artworks without writing files or updating metadata}';
|
||||
|
||||
protected $description = 'Wrap artwork originals with unsupported file extensions into zip archives and update artwork metadata.';
|
||||
|
||||
private const ZIP_MIME = 'application/zip';
|
||||
|
||||
/**
|
||||
* Extensions that can stay as-is because they are already images or well-known archives.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SUPPORTED_EXTENSIONS = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tif',
|
||||
'tiff',
|
||||
'svg',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'ico',
|
||||
'jfif',
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'7zip',
|
||||
'tar',
|
||||
'gz',
|
||||
'tgz',
|
||||
'bz2',
|
||||
'xz',
|
||||
];
|
||||
|
||||
public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
|
||||
{
|
||||
$artworkId = $this->resolveArtworkIdOption();
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
|
||||
$force = (bool) $this->option('force');
|
||||
$deleteOriginalObject = (bool) $this->option('delete-original-object');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting unsupported artwork original zip pass. chunk=%d limit=%s dry_run=%s force=%s delete_original_object=%s',
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$dryRun ? 'yes' : 'no',
|
||||
$force ? 'yes' : 'no',
|
||||
$deleteOriginalObject ? 'yes' : 'no',
|
||||
));
|
||||
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'title', 'slug', 'file_name', 'file_path', 'hash', 'file_ext', 'mime_type', 'file_size'])
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$skippedSupported = 0;
|
||||
$skippedUnresolved = 0;
|
||||
$skippedMissingSource = 0;
|
||||
$wouldFixMetadata = 0;
|
||||
$wouldConvert = 0;
|
||||
$metadataFixed = 0;
|
||||
$converted = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById($chunkSize, function ($artworks) use (
|
||||
$locator,
|
||||
$storage,
|
||||
$limit,
|
||||
$force,
|
||||
$deleteOriginalObject,
|
||||
$dryRun,
|
||||
&$processed,
|
||||
&$skippedSupported,
|
||||
&$skippedUnresolved,
|
||||
&$skippedMissingSource,
|
||||
&$wouldFixMetadata,
|
||||
&$wouldConvert,
|
||||
&$metadataFixed,
|
||||
&$converted,
|
||||
&$failed,
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->processArtwork($artwork, $locator, $storage, $dryRun, $deleteOriginalObject, $force);
|
||||
|
||||
match ($result) {
|
||||
'skipped_supported' => $skippedSupported++,
|
||||
'skipped_unresolved' => $skippedUnresolved++,
|
||||
'skipped_missing_source' => $skippedMissingSource++,
|
||||
'would_fix_metadata' => $wouldFixMetadata++,
|
||||
'would_convert' => $wouldConvert++,
|
||||
'fixed_metadata' => $metadataFixed++,
|
||||
'converted' => $converted++,
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
}
|
||||
|
||||
$processed++;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Unsupported artwork original zip pass complete. processed=%d skipped_supported=%d skipped_unresolved=%d skipped_missing_source=%d would_fix_metadata=%d would_convert=%d metadata_fixed=%d converted=%d failed=%d',
|
||||
$processed,
|
||||
$skippedSupported,
|
||||
$skippedUnresolved,
|
||||
$skippedMissingSource,
|
||||
$wouldFixMetadata,
|
||||
$wouldConvert,
|
||||
$metadataFixed,
|
||||
$converted,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveArtworkIdOption(): ?int
|
||||
{
|
||||
$artworkId = $this->option('artwork-id');
|
||||
if ($artworkId !== null) {
|
||||
return max(1, (int) $artworkId);
|
||||
}
|
||||
|
||||
$legacyId = $this->option('id');
|
||||
if ($legacyId !== null) {
|
||||
return max(1, (int) $legacyId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function processArtwork(Artwork $artwork, ArtworkOriginalFileLocator $locator, UploadStorageService $storage, bool $dryRun, bool $deleteOriginalObject, bool $force): string
|
||||
{
|
||||
$metadataExtension = $this->normalizeExtension((string) $artwork->file_ext);
|
||||
if (! $force && $this->isSupportedExtension($metadataExtension)) {
|
||||
return 'skipped_supported';
|
||||
}
|
||||
|
||||
$resolvedLocalPath = $locator->resolveLocalPath($artwork);
|
||||
$resolvedObjectPath = $locator->resolveObjectPath($artwork);
|
||||
|
||||
$hash = strtolower(trim((string) $artwork->hash));
|
||||
if (! $this->isValidHash($hash)) {
|
||||
$this->line(sprintf('Artwork %d skipped: invalid or missing hash.', (int) $artwork->id));
|
||||
$this->writeArtworkContext($artwork);
|
||||
|
||||
return 'skipped_unresolved';
|
||||
}
|
||||
|
||||
$targetLocalPath = $storage->localOriginalPath($hash, $hash . '.zip');
|
||||
$targetObjectPath = $storage->objectPathForVariant('original', $hash, $hash . '.zip');
|
||||
$source = $this->prepareSourceFile($resolvedLocalPath, $resolvedObjectPath, $storage, $hash, $force);
|
||||
|
||||
if ($source === null) {
|
||||
$this->line(sprintf('Artwork %d skipped: source file not found.', (int) $artwork->id));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$this->writeVerbosePaths($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
|
||||
|
||||
return 'skipped_missing_source';
|
||||
}
|
||||
|
||||
$sourceExtension = $this->detectSourceExtension($source['path'], $resolvedObjectPath);
|
||||
|
||||
if (! $force && $this->isSupportedExtension($sourceExtension)) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'Artwork %d would fix metadata only: file_ext=%s -> %s',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
$sourceExtension,
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
|
||||
return 'would_fix_metadata';
|
||||
}
|
||||
|
||||
$size = $this->detectFileSize($source['path'], $artwork->file_size);
|
||||
$mime = $this->detectMimeType($source['path'], $artwork->mime_type, $sourceExtension);
|
||||
$updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), $sourceExtension);
|
||||
|
||||
$this->persistArtworkMetadata((int) $artwork->id, $resolvedObjectPath !== '' ? $resolvedObjectPath : null, $sourceExtension, $mime, $size, $updatedFileName);
|
||||
$this->info(sprintf(
|
||||
'Artwork %d metadata fixed: file_ext=%s -> %s',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
$sourceExtension,
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
|
||||
return 'fixed_metadata';
|
||||
}
|
||||
|
||||
if ($force && $this->isSupportedExtension($sourceExtension)) {
|
||||
$this->line(sprintf(
|
||||
'Artwork %d skipped: force requested but no non-archive source was found.',
|
||||
(int) $artwork->id,
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
|
||||
|
||||
return 'skipped_supported';
|
||||
}
|
||||
|
||||
try {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'Artwork %d would be archived: file_ext=%s -> zip',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
|
||||
|
||||
return 'would_convert';
|
||||
}
|
||||
|
||||
$archiveEntryName = $this->resolveArchiveEntryName($artwork, $metadataExtension, $sourceExtension);
|
||||
$temporaryZipPath = $this->createZipArchive($source['path'], $archiveEntryName);
|
||||
|
||||
try {
|
||||
$this->publishZipArchive($temporaryZipPath, $targetLocalPath, $targetObjectPath, $storage);
|
||||
$size = (int) (filesize($targetLocalPath) ?: 0);
|
||||
$updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), 'zip');
|
||||
|
||||
$this->persistArtworkMetadata((int) $artwork->id, $targetObjectPath, 'zip', self::ZIP_MIME, $size, $updatedFileName);
|
||||
$this->deleteLegacySource($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $storage, $deleteOriginalObject);
|
||||
} catch (Throwable $exception) {
|
||||
$this->cleanupTargetArtifacts($targetLocalPath, $targetObjectPath, $storage);
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
File::delete($temporaryZipPath);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Artwork %d archived to zip: file_ext=%s -> zip',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$deletedOldObjectPath = $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
|
||||
? $resolvedObjectPath
|
||||
: '';
|
||||
$keptOldObjectPath = ! $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
|
||||
? $resolvedObjectPath
|
||||
: '';
|
||||
|
||||
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $deletedOldObjectPath, $keptOldObjectPath);
|
||||
|
||||
return 'converted';
|
||||
} finally {
|
||||
if ($source['temporary']) {
|
||||
File::delete($source['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path: string, temporary: bool}|null
|
||||
*/
|
||||
private function prepareSourceFile(string $resolvedLocalPath, string $resolvedObjectPath, UploadStorageService $storage, string $hash, bool $force): ?array
|
||||
{
|
||||
if ($force) {
|
||||
$forcedSourcePath = $this->resolveForceSourcePath($hash);
|
||||
if ($forcedSourcePath !== '') {
|
||||
return [
|
||||
'path' => $forcedSourcePath,
|
||||
'temporary' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
|
||||
return [
|
||||
'path' => $resolvedLocalPath,
|
||||
'temporary' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$backupSourcePath = $this->resolveReadonlyBackupSourcePath($resolvedObjectPath);
|
||||
if ($backupSourcePath !== '' && File::isFile($backupSourcePath)) {
|
||||
return [
|
||||
'path' => $backupSourcePath,
|
||||
'temporary' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($resolvedObjectPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($storage->objectDiskName());
|
||||
if (! $disk->exists($resolvedObjectPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stream = $disk->readStream($resolvedObjectPath);
|
||||
if (! is_resource($stream)) {
|
||||
throw new RuntimeException('Unable to open source object stream.');
|
||||
}
|
||||
|
||||
$temporaryPath = tempnam(sys_get_temp_dir(), 'art-src-');
|
||||
if ($temporaryPath === false) {
|
||||
fclose($stream);
|
||||
|
||||
throw new RuntimeException('Unable to allocate a temporary source file.');
|
||||
}
|
||||
|
||||
$target = fopen($temporaryPath, 'wb');
|
||||
if (! is_resource($target)) {
|
||||
fclose($stream);
|
||||
File::delete($temporaryPath);
|
||||
|
||||
throw new RuntimeException('Unable to open a temporary source file for writing.');
|
||||
}
|
||||
|
||||
try {
|
||||
$copied = stream_copy_to_stream($stream, $target);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
fclose($target);
|
||||
}
|
||||
|
||||
if ($copied === false || $copied <= 0 || ! File::isFile($temporaryPath)) {
|
||||
File::delete($temporaryPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $temporaryPath,
|
||||
'temporary' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function createZipArchive(string $sourcePath, string $archiveEntryName): string
|
||||
{
|
||||
$temporaryPath = tempnam(sys_get_temp_dir(), 'art-zip-');
|
||||
if ($temporaryPath === false) {
|
||||
throw new RuntimeException('Unable to allocate a temporary zip file.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$opened = $zip->open($temporaryPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
if ($opened !== true) {
|
||||
File::delete($temporaryPath);
|
||||
|
||||
throw new RuntimeException('Unable to create zip archive.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $zip->addFile($sourcePath, $archiveEntryName)) {
|
||||
throw new RuntimeException('Unable to add artwork original to zip archive.');
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
if (! File::isFile($temporaryPath)) {
|
||||
throw new RuntimeException('Zip archive was not written to disk.');
|
||||
}
|
||||
|
||||
return $temporaryPath;
|
||||
}
|
||||
|
||||
private function publishZipArchive(string $temporaryZipPath, string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($targetLocalPath));
|
||||
File::delete($targetLocalPath);
|
||||
|
||||
if (! File::copy($temporaryZipPath, $targetLocalPath)) {
|
||||
throw new RuntimeException('Unable to write local zip archive.');
|
||||
}
|
||||
|
||||
$storage->putObjectFromPath($targetLocalPath, $targetObjectPath, self::ZIP_MIME);
|
||||
}
|
||||
|
||||
private function cleanupTargetArtifacts(string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
|
||||
{
|
||||
$storage->deleteLocalFile($targetLocalPath);
|
||||
$storage->deleteObject($targetObjectPath);
|
||||
}
|
||||
|
||||
private function deleteLegacySource(string $resolvedLocalPath, string $targetLocalPath, string $resolvedObjectPath, string $targetObjectPath, UploadStorageService $storage, bool $deleteOriginalObject): void
|
||||
{
|
||||
if ($resolvedLocalPath !== '' && $this->samePath($resolvedLocalPath, $targetLocalPath) === false) {
|
||||
$storage->deleteLocalFile($resolvedLocalPath);
|
||||
}
|
||||
|
||||
if ($deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath) {
|
||||
$storage->deleteObject($resolvedObjectPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function persistArtworkMetadata(int $artworkId, ?string $filePath, string $fileExt, string $mimeType, int $fileSize, ?string $fileName = null): void
|
||||
{
|
||||
$values = [
|
||||
'file_path' => $filePath,
|
||||
'file_ext' => $fileExt,
|
||||
'mime_type' => $mimeType,
|
||||
'file_size' => max(0, $fileSize),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($fileName !== null && trim($fileName) !== '') {
|
||||
$values['file_name'] = $fileName;
|
||||
}
|
||||
|
||||
DB::table('artworks')
|
||||
->where('id', $artworkId)
|
||||
->update($values);
|
||||
}
|
||||
|
||||
private function resolveArchiveEntryName(Artwork $artwork, string $metadataExtension, string $sourceExtension): string
|
||||
{
|
||||
$candidate = trim((string) pathinfo((string) $artwork->file_name, PATHINFO_FILENAME));
|
||||
$candidate = str_replace(['/', '\\'], '-', $candidate);
|
||||
$candidate = trim((string) preg_replace('/[\x00-\x1F\x7F]/', '', $candidate));
|
||||
$candidate = trim($candidate, ". \t\n\r\0\x0B");
|
||||
|
||||
$extension = $sourceExtension !== '' ? $sourceExtension : $metadataExtension;
|
||||
|
||||
if ($candidate !== '' && $candidate !== '.' && $candidate !== '..') {
|
||||
return $extension !== ''
|
||||
? $candidate . '.' . $extension
|
||||
: $candidate;
|
||||
}
|
||||
|
||||
if ($extension !== '') {
|
||||
return (string) $artwork->hash . '.' . $extension;
|
||||
}
|
||||
|
||||
return ((string) $artwork->hash !== '' ? (string) $artwork->hash : 'artwork') . '.bin';
|
||||
}
|
||||
|
||||
private function detectSourceExtension(string $resolvedLocalPath, string $resolvedObjectPath): string
|
||||
{
|
||||
$path = $resolvedLocalPath !== '' ? $resolvedLocalPath : $resolvedObjectPath;
|
||||
|
||||
return $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
private function detectMimeType(string $resolvedLocalPath, ?string $fallbackMimeType, string $extension): string
|
||||
{
|
||||
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
|
||||
$detected = File::mimeType($resolvedLocalPath);
|
||||
if (is_string($detected) && $detected !== '') {
|
||||
return $detected;
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = trim((string) $fallbackMimeType);
|
||||
if ($fallback !== '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return match ($extension) {
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'bmp' => 'image/bmp',
|
||||
'tif', 'tiff' => 'image/tiff',
|
||||
'svg' => 'image/svg+xml',
|
||||
'avif' => 'image/avif',
|
||||
'heic' => 'image/heic',
|
||||
'heif' => 'image/heif',
|
||||
'ico' => 'image/x-icon',
|
||||
'zip' => self::ZIP_MIME,
|
||||
'rar' => 'application/vnd.rar',
|
||||
'7z', '7zip' => 'application/x-7z-compressed',
|
||||
'tar' => 'application/x-tar',
|
||||
'gz', 'tgz' => 'application/gzip',
|
||||
'bz2' => 'application/x-bzip2',
|
||||
'xz' => 'application/x-xz',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
private function detectFileSize(string $resolvedLocalPath, ?int $fallbackSize): int
|
||||
{
|
||||
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
|
||||
$size = filesize($resolvedLocalPath);
|
||||
if ($size !== false) {
|
||||
return (int) $size;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, (int) $fallbackSize);
|
||||
}
|
||||
|
||||
private function resolveFileNameWithExtension(string $fileName, string $extension): string
|
||||
{
|
||||
$name = trim($fileName);
|
||||
$name = str_replace(['/', '\\'], '-', $name);
|
||||
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
|
||||
$name = preg_replace('/\s+/', ' ', $name) ?? '';
|
||||
$name = trim((string) $name, ". \t\n\r\0\x0B");
|
||||
|
||||
$baseName = trim((string) pathinfo($name, PATHINFO_FILENAME), ". \t\n\r\0\x0B");
|
||||
if ($baseName === '') {
|
||||
$baseName = 'artwork';
|
||||
}
|
||||
|
||||
$normalizedExtension = $this->normalizeExtension($extension);
|
||||
|
||||
return $normalizedExtension !== ''
|
||||
? $baseName . '.' . $normalizedExtension
|
||||
: $baseName;
|
||||
}
|
||||
|
||||
private function normalizeExtension(string $extension): string
|
||||
{
|
||||
return strtolower(ltrim(trim($extension), '.'));
|
||||
}
|
||||
|
||||
private function isSupportedExtension(string $extension): bool
|
||||
{
|
||||
return $extension !== '' && in_array($extension, self::SUPPORTED_EXTENSIONS, true);
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
|
||||
private function samePath(string $left, string $right): bool
|
||||
{
|
||||
$normalizedLeft = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $left);
|
||||
$normalizedRight = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $right);
|
||||
|
||||
return $normalizedLeft === $normalizedRight;
|
||||
}
|
||||
|
||||
private function resolveForceSourcePath(string $hash): string
|
||||
{
|
||||
if (! $this->isValidHash($hash)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($this->candidateOriginalRoots() as $root) {
|
||||
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
|
||||
if ($candidatePath !== '') {
|
||||
return $candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function candidateOriginalRoots(): array
|
||||
{
|
||||
$roots = [
|
||||
trim((string) config('uploads.local_originals_root', '')),
|
||||
trim((string) config('uploads.readonly_backup_originals_root', '')),
|
||||
];
|
||||
|
||||
$normalizedRoots = [];
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if ($root === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoots[] = $normalizedRoot;
|
||||
}
|
||||
|
||||
return $normalizedRoots;
|
||||
}
|
||||
|
||||
private function findNonZipSourceInRoot(string $root, string $hash): string
|
||||
{
|
||||
$directory = $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
|
||||
|
||||
if (! File::isDirectory($directory)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
|
||||
if (! is_array($matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (! is_string($path) || ! File::isFile($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
if ($extension === '' || $extension === 'zip') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolveReadonlyBackupSourcePath(string $resolvedObjectPath): string
|
||||
{
|
||||
$root = trim((string) config('uploads.readonly_backup_originals_root', ''));
|
||||
if ($root === '' || $resolvedObjectPath === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
$filename = (string) pathinfo($resolvedObjectPath, PATHINFO_BASENAME);
|
||||
$hash = strtolower((string) pathinfo($filename, PATHINFO_FILENAME));
|
||||
$extension = $this->normalizeExtension((string) pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if (! $this->isValidHash($hash) || $extension === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $normalizedRoot
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
|
||||
. DIRECTORY_SEPARATOR . $hash . '.' . $extension;
|
||||
}
|
||||
|
||||
private function writeVerbosePaths(
|
||||
string $sourcePath,
|
||||
string $targetLocalPath,
|
||||
string $sourceObjectPath = '',
|
||||
string $targetObjectPath = '',
|
||||
string $deletedOldObjectPath = '',
|
||||
string $keptOldObjectPath = '',
|
||||
): void
|
||||
{
|
||||
$displaySourcePath = $sourcePath !== '' ? $sourcePath : '(unresolved local source path)';
|
||||
|
||||
$this->line(' source_file: ' . $displaySourcePath);
|
||||
if ($sourceObjectPath !== '') {
|
||||
$this->line(' source_object: ' . $sourceObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
|
||||
}
|
||||
$this->line(' new_zip_file: ' . $targetLocalPath);
|
||||
if ($targetObjectPath !== '') {
|
||||
$this->line(' new_zip_object: ' . $targetObjectPath);
|
||||
}
|
||||
if ($deletedOldObjectPath !== '') {
|
||||
$this->line(' deleted_old_object: ' . $deletedOldObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
|
||||
}
|
||||
if ($keptOldObjectPath !== '') {
|
||||
$this->line(' kept_original_object: ' . $keptOldObjectPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function writeArtworkContext(Artwork $artwork): void
|
||||
{
|
||||
$this->line(' title: ' . trim((string) ($artwork->title ?? '')));
|
||||
$this->line(' artwork_url: ' . route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Console\Commands\BackfillArtworkVectorIndexCommand;
|
||||
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AcademyCoursesSyncFoundationsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||
@@ -24,11 +25,6 @@ use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Console\Commands\RecalculateRankingsCommand;
|
||||
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
||||
use App\Console\Commands\RecalculateHeatCommand;
|
||||
use App\Jobs\UpdateLeaderboardsJob;
|
||||
use App\Jobs\RebuildTrendingNovaCardsJob;
|
||||
use App\Jobs\RecalculateRisingNovaCardsJob;
|
||||
use App\Jobs\RankComputeArtworkScoresJob;
|
||||
use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
use App\Console\Commands\NormalizeArtworkSlugsCommand;
|
||||
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||
@@ -40,7 +36,10 @@ use App\Console\Commands\PublishSitemapsCommand;
|
||||
use App\Console\Commands\RollbackSitemapReleaseCommand;
|
||||
use App\Console\Commands\SyncCollectionLifecycleCommand;
|
||||
use App\Console\Commands\ValidateSitemapsCommand;
|
||||
use App\Jobs\Sitemaps\CleanupSitemapReleasesJob;
|
||||
use App\Console\Commands\AuditArtworkDownloadFilesCommand;
|
||||
use App\Console\Commands\InspectArtworkOriginalCommand;
|
||||
use App\Console\Commands\ZipUnsupportedArtworkOriginalsCommand;
|
||||
use App\Console\Commands\SendTestMail;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@@ -68,7 +67,12 @@ class Kernel extends ConsoleKernel
|
||||
PublishScheduledNovaCardsCommand::class,
|
||||
SyncCollectionLifecycleCommand::class,
|
||||
ValidateSitemapsCommand::class,
|
||||
AuditArtworkDownloadFilesCommand::class,
|
||||
InspectArtworkOriginalCommand::class,
|
||||
ZipUnsupportedArtworkOriginalsCommand::class,
|
||||
SendTestMail::class,
|
||||
DispatchCollectionMaintenanceCommand::class,
|
||||
AcademyCoursesSyncFoundationsCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
BackfillArtworkVectorIndexCommand::class,
|
||||
IndexArtworkVectorsCommand::class,
|
||||
@@ -92,6 +96,7 @@ class Kernel extends ConsoleKernel
|
||||
\App\Console\Commands\AuditOrphanedArtworksCommand::class,
|
||||
\App\Console\Commands\FlagLegacyUsersForMigrationCommand::class,
|
||||
\App\Console\Commands\ExportLegacyPasswordsCommand::class,
|
||||
\App\Console\Commands\HashLegacyPlainPasswordsCommand::class,
|
||||
\App\Console\Commands\GenerateAiBiographyCommand::class,
|
||||
\App\Console\Commands\InspectAiBiographyCommand::class,
|
||||
\App\Console\Commands\ReviewQueueAiBiographyCommand::class,
|
||||
@@ -103,119 +108,8 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||
{
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
|
||||
$schedule->command('skinbase:sitemaps:publish --sync')
|
||||
->everySixHours()
|
||||
->name('sitemaps-publish')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('skinbase:sitemaps:validate')
|
||||
->dailyAt('04:45')
|
||||
->name('sitemaps-validate')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
$schedule->job(new CleanupSitemapReleasesJob)
|
||||
->dailyAt('05:00')
|
||||
->name('sitemaps-cleanup')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Publish artworks whose scheduled publish_at has passed
|
||||
$schedule->command('artworks:publish-scheduled')
|
||||
->everyMinute()
|
||||
->name('publish-scheduled-artworks')
|
||||
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
||||
->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();
|
||||
$schedule->command('collections:dispatch-maintenance')
|
||||
->hourly()
|
||||
->name('dispatch-collection-maintenance')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
|
||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
||||
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||
|
||||
// ── Ranking system (rank_v1) ────────────────────────────────────────
|
||||
// Step 1: compute per-artwork scores every hour at :05
|
||||
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
||||
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->withoutOverlapping()->runInBackground();
|
||||
|
||||
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
|
||||
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
|
||||
->everyThirtyMinutes()
|
||||
->name('ranking-v2')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
$schedule->job(new UpdateLeaderboardsJob)
|
||||
->hourlyAt(20)
|
||||
->name('leaderboards-refresh')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
$schedule->job(new RebuildTrendingNovaCardsJob)
|
||||
->hourlyAt(25)
|
||||
->name('nova-cards-trending-refresh')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
|
||||
// Step 1: snapshot metric totals every hour at :00
|
||||
$schedule->command('nova:metrics-snapshot-hourly')
|
||||
->hourly()
|
||||
->name('metrics-snapshot-hourly')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 2: recalculate heat scores every 15 minutes
|
||||
$schedule->command('nova:recalculate-heat')
|
||||
->everyFifteenMinutes()
|
||||
->name('recalculate-heat')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 2b: bust Nova Cards v3 rising feed cache to stay in sync
|
||||
$schedule->job(new RecalculateRisingNovaCardsJob)
|
||||
->everyFifteenMinutes()
|
||||
->name('nova-cards-rising-cache-refresh')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 3: prune old snapshots daily at 04:00
|
||||
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
||||
->dailyAt('04:00');
|
||||
|
||||
$schedule->command('skinbase:sync-countries')
|
||||
->monthlyOn(1, '03:40')
|
||||
->name('sync-countries')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Scheduler health heartbeat ──────────────────────────────────────
|
||||
// Stamps a Redis key each minute so `health:check --only=scheduler` can
|
||||
// verify cron is alive. The key expires after 5 minutes so a dead cron
|
||||
// will naturally cause the check to warn/fail.
|
||||
$schedule->command('health:tick')
|
||||
->everyMinute()
|
||||
->name('health-scheduler-tick');
|
||||
// The active runtime schedule lives in routes/console.php via bootstrap/app.php.
|
||||
// Keep the kernel empty so recurring work is not registered twice.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
47
app/Enums/UserRole.php
Normal file
47
app/Enums/UserRole.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
case User = 'user';
|
||||
case Creator = 'creator';
|
||||
case Moderator = 'moderator';
|
||||
case Editorial = 'editorial';
|
||||
case Manager = 'manager';
|
||||
case Admin = 'admin';
|
||||
|
||||
/** Roles that grant access to the /admin panel. */
|
||||
public static function staffRoles(): array
|
||||
{
|
||||
return [self::Editorial, self::Manager, self::Admin];
|
||||
}
|
||||
|
||||
/** Human-friendly label. */
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::User => 'User',
|
||||
self::Creator => 'Creator',
|
||||
self::Moderator => 'Moderator',
|
||||
self::Editorial => 'Editorial',
|
||||
self::Manager => 'Manager',
|
||||
self::Admin => 'Admin',
|
||||
};
|
||||
}
|
||||
|
||||
/** Badge color class (Tailwind). */
|
||||
public function badgeClass(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::User => 'bg-slate-500/20 text-slate-300',
|
||||
self::Creator => 'bg-sky-500/20 text-sky-300',
|
||||
self::Moderator => 'bg-violet-500/20 text-violet-300',
|
||||
self::Editorial => 'bg-teal-500/20 text-teal-300',
|
||||
self::Manager => 'bg-amber-500/20 text-amber-300',
|
||||
self::Admin => 'bg-rose-500/20 text-rose-300',
|
||||
};
|
||||
}
|
||||
}
|
||||
60
app/Enums/WorldRewardType.php
Normal file
60
app/Enums/WorldRewardType.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum WorldRewardType: string
|
||||
{
|
||||
case Participant = 'participant';
|
||||
case Featured = 'featured';
|
||||
case Finalist = 'finalist';
|
||||
case Winner = 'winner';
|
||||
case Spotlight = 'spotlight';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Participant => 'Participant',
|
||||
self::Featured => 'Featured',
|
||||
self::Finalist => 'Finalist',
|
||||
self::Winner => 'Winner',
|
||||
self::Spotlight => 'Spotlight',
|
||||
};
|
||||
}
|
||||
|
||||
public function tone(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Participant => 'sky',
|
||||
self::Featured => 'amber',
|
||||
self::Finalist => 'violet',
|
||||
self::Winner => 'emerald',
|
||||
self::Spotlight => 'rose',
|
||||
};
|
||||
}
|
||||
|
||||
public function source(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Participant, self::Featured => 'automatic',
|
||||
self::Finalist, self::Winner, self::Spotlight => 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
public function isAutomatic(): bool
|
||||
{
|
||||
return $this->source() === 'automatic';
|
||||
}
|
||||
|
||||
public function xpReward(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Participant => 20,
|
||||
self::Featured => 40,
|
||||
self::Finalist => 70,
|
||||
self::Winner => 120,
|
||||
self::Spotlight => 55,
|
||||
};
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/Academy/AcademyChallengeController.php
Normal file
97
app/Http/Controllers/Academy/AcademyChallengeController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyChallengeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AcademyAccessService $access)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless((bool) config('academy.challenges_enabled', true), 404);
|
||||
|
||||
$challenges = AcademyChallenge::query()
|
||||
->publiclyVisible()
|
||||
->latest('starts_at')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
$challenges->getCollection()->transform(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true));
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionListing(
|
||||
'Academy Challenges — Skinbase',
|
||||
'Join Academy creative briefs, review accepted submissions, and explore current and archived AI Academy challenges.',
|
||||
route('academy.challenges.index'),
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'challenges',
|
||||
'title' => 'Academy challenges',
|
||||
'description' => 'Creative briefs for wallpapers, worlds, covers, and prompt-driven visual experiments.',
|
||||
'seo' => $seo,
|
||||
'items' => $challenges,
|
||||
'filters' => [],
|
||||
'categories' => [],
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless((bool) config('academy.challenges_enabled', true), 404);
|
||||
|
||||
$challenge = AcademyChallenge::query()
|
||||
->with(['submissions' => fn ($query) => $query->approved()->with(['user:id,name,username', 'artwork'])])
|
||||
->publiclyVisible()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->access->challengePayload($challenge, $request->user(), true);
|
||||
$payload['submissions'] = $challenge->submissions->map(fn ($submission): array => [
|
||||
'id' => (int) $submission->id,
|
||||
'user' => $submission->user ? [
|
||||
'id' => (int) $submission->user->id,
|
||||
'name' => (string) $submission->user->name,
|
||||
'username' => (string) ($submission->user->username ?? ''),
|
||||
] : null,
|
||||
'artwork' => $submission->artwork ? [
|
||||
'id' => (int) $submission->artwork->id,
|
||||
'title' => (string) ($submission->artwork->title ?? 'Untitled artwork'),
|
||||
'thumb_url' => $submission->artwork->thumbUrl('md'),
|
||||
] : null,
|
||||
'submitted_at' => $submission->submitted_at?->toISOString(),
|
||||
])->values()->all();
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$challenge->title . ' — Skinbase Academy',
|
||||
Str::limit((string) ($challenge->excerpt ?? $challenge->description ?? ''), 160, '...'),
|
||||
route('academy.challenges.show', ['slug' => $challenge->slug]),
|
||||
$challenge->cover_image,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'challenge',
|
||||
'item' => $payload,
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Academy\StoreAcademyChallengeSubmissionRequest;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyChallengeService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
final class AcademyChallengeSubmissionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyChallengeService $challenges,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(string $slug): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless((bool) config('academy.challenges_enabled', true), 404);
|
||||
|
||||
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
|
||||
abort_unless($this->access->canAccessChallenge(request()->user(), $challenge), 403);
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
'Submit to ' . $challenge->title . ' — Skinbase Academy',
|
||||
'Attach one of your artworks to this Academy challenge submission.',
|
||||
route('academy.challenges.submit', ['slug' => $challenge->slug]),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/ChallengeSubmit', [
|
||||
'seo' => $seo,
|
||||
'challenge' => $this->access->challengePayload($challenge, request()->user(), true),
|
||||
'artworks' => $this->challenges->eligibleArtworkOptions(request()->user())->map(fn (Artwork $artwork): array => [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'published_at' => $artwork->published_at?->toISOString(),
|
||||
])->values()->all(),
|
||||
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless((bool) config('academy.challenges_enabled', true), 404);
|
||||
|
||||
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
|
||||
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
|
||||
|
||||
$this->challenges->submit($request->user(), $challenge, $artwork, $request->validated());
|
||||
|
||||
return redirect()->route('academy.challenges.show', ['slug' => $challenge->slug])
|
||||
->with('success', 'Challenge submission received and queued for review.');
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Academy/AcademyCheckoutController.php
Normal file
41
app/Http/Controllers/Academy/AcademyCheckoutController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class AcademyCheckoutController extends Controller
|
||||
{
|
||||
public function store(Request $request, string $plan): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
// TODO: Replace this placeholder with Laravel Cashier + Stripe Checkout when academy payments are enabled.
|
||||
if (! (bool) config('academy.payments_enabled', false)) {
|
||||
$payload = [
|
||||
'ok' => false,
|
||||
'code' => 'academy_payments_disabled',
|
||||
'message' => 'Academy payments are disabled for this launch phase.',
|
||||
'plan' => $plan,
|
||||
];
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($payload, 423);
|
||||
}
|
||||
|
||||
return redirect()->route('academy.pricing')->with('error', $payload['message']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'code' => 'academy_checkout_not_implemented',
|
||||
'message' => 'Checkout is not implemented yet.',
|
||||
'plan' => $plan,
|
||||
], 501);
|
||||
}
|
||||
}
|
||||
184
app/Http/Controllers/Academy/AcademyCourseController.php
Normal file
184
app/Http/Controllers/Academy/AcademyCourseController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyCourseNavigationService;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyCourseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCacheService $cache,
|
||||
private readonly AcademyCourseNavigationService $navigation,
|
||||
private readonly AcademyCourseProgressService $progress,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$filters = $request->validate([
|
||||
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||
'access' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$query = AcademyCourse::query()->published()->ordered();
|
||||
|
||||
if (filled($filters['difficulty'] ?? null)) {
|
||||
$query->where('difficulty', $filters['difficulty']);
|
||||
}
|
||||
|
||||
if (filled($filters['access'] ?? null)) {
|
||||
$query->where('access_level', $filters['access']);
|
||||
}
|
||||
|
||||
$courses = $query->paginate(12)->withQueryString();
|
||||
$courses->getCollection()->transform(function (AcademyCourse $course) use ($request): array {
|
||||
return $this->access->coursePayload($course, $request->user(), [
|
||||
'progress' => $this->progress->getProgress($request->user(), $course),
|
||||
]);
|
||||
});
|
||||
|
||||
$featuredCourses = collect($this->cache->featuredCourses())->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user(), [
|
||||
'progress' => $this->progress->getProgress($request->user(), $course),
|
||||
]))->values();
|
||||
|
||||
$seoCourses = $featuredCourses
|
||||
->concat(collect($courses->items()))
|
||||
->unique(fn (array $course): string => (string) ($course['slug'] ?? ''))
|
||||
->values();
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->academyCourseListingPage(
|
||||
'Academy Courses — Skinbase',
|
||||
'Follow guided Skinbase AI Academy courses built from reusable lessons, chapters, and creator workflows.',
|
||||
route('academy.courses.index', $request->query()),
|
||||
$seoCourses,
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||
],
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/CoursesIndex', [
|
||||
'seo' => $seo,
|
||||
'title' => 'Academy courses',
|
||||
'description' => 'Guided learning paths built from reusable Academy lessons and creator workflows.',
|
||||
'items' => $courses,
|
||||
'featuredCourses' => $featuredCourses->all(),
|
||||
'filters' => $filters,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, AcademyCourse $course): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($course->isPublished(), 404);
|
||||
|
||||
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||
|
||||
$progress = $this->progress->getProgress($request->user(), $course);
|
||||
$completedLessonIds = $request->user() ? $this->progress->getCompletedLessonIds($request->user(), $course) : [];
|
||||
$orderedLessons = $this->navigation->orderedCourseLessons($course);
|
||||
$stepMeta = $orderedLessons
|
||||
->values()
|
||||
->mapWithKeys(fn (AcademyCourseLesson $courseLesson, int $index): array => [
|
||||
$courseLesson->id => [
|
||||
'course_step_number' => $index + 1,
|
||||
'course_step_label' => sprintf('Step %02d', $index + 1),
|
||||
],
|
||||
]);
|
||||
$sections = $course->sections
|
||||
->sortBy([['order_num', 'asc'], ['id', 'asc']])
|
||||
->values()
|
||||
->map(function ($section) use ($completedLessonIds, $orderedLessons, $request, $stepMeta): array {
|
||||
$sectionLessons = $orderedLessons
|
||||
->where('section_id', $section->id)
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||
'completed_lesson_ids' => $completedLessonIds,
|
||||
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||
]))
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => (int) $section->id,
|
||||
'title' => (string) $section->title,
|
||||
'slug' => (string) ($section->slug ?? ''),
|
||||
'description' => (string) ($section->description ?? ''),
|
||||
'order_num' => (int) ($section->order_num ?? 0),
|
||||
'is_visible' => (bool) ($section->is_visible ?? true),
|
||||
'lessons' => $sectionLessons,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
$unsectionedLessons = $orderedLessons
|
||||
->whereNull('section_id')
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||
'completed_lesson_ids' => $completedLessonIds,
|
||||
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||
]))
|
||||
->all();
|
||||
|
||||
$coursePayload = $this->access->coursePayload($course, $request->user(), ['progress' => $progress]);
|
||||
$courseKeywords = collect(explode(',', (string) ($course->meta_keywords ?? '')))
|
||||
->map(fn (string $keyword): string => trim($keyword))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$courseImage = (string) ($coursePayload['cover_image_url'] ?? $coursePayload['teaser_image_url'] ?? $course->og_image ?? $course->cover_image ?? $course->teaser_image ?? '');
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->academyCoursePage(
|
||||
(string) ($course->seo_title ?: ($course->title . ' — Skinbase Academy')),
|
||||
(string) ($course->seo_description ?: $course->excerpt ?: 'Skinbase Academy course'),
|
||||
route('academy.courses.show', ['course' => $course->slug]),
|
||||
$courseImage,
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
|
||||
],
|
||||
$courseKeywords,
|
||||
$course->published_at?->toAtomString(),
|
||||
$course->updated_at?->toAtomString(),
|
||||
(string) ($course->access_level ?? ''),
|
||||
(string) ($course->difficulty ?? ''),
|
||||
(int) ($course->estimated_minutes ?? 0),
|
||||
$orderedLessons
|
||||
->values()
|
||||
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
|
||||
'completed_lesson_ids' => $completedLessonIds,
|
||||
...((array) $stepMeta->get($courseLesson->id, [])),
|
||||
]))
|
||||
->all(),
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/CoursesShow', [
|
||||
'seo' => $seo,
|
||||
'course' => $coursePayload,
|
||||
'sections' => $sections,
|
||||
'unsectionedLessons' => $unsectionedLessons,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class AcademyCourseEnrollmentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AcademyCourseProgressService $progress)
|
||||
{
|
||||
}
|
||||
|
||||
public function start(Request $request, AcademyCourse $course): RedirectResponse
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($course->isPublished(), 404);
|
||||
|
||||
$this->progress->markEnrollmentStarted($request->user(), $course);
|
||||
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
|
||||
|
||||
if ($continueLesson?->lesson) {
|
||||
return redirect()->route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $continueLesson->lesson->slug]);
|
||||
}
|
||||
|
||||
return redirect()->route('academy.courses.show', ['course' => $course->slug]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCourseNavigationService;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyCourseLessonController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCourseNavigationService $navigation,
|
||||
private readonly AcademyCourseProgressService $progress,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, AcademyCourse $course, AcademyLesson $lesson): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
abort_unless($course->isPublished(), 404);
|
||||
|
||||
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
|
||||
$courseLesson = $this->navigation->findCourseLesson($course, $lesson);
|
||||
|
||||
abort_unless($courseLesson instanceof \App\Models\AcademyCourseLesson, 404);
|
||||
|
||||
if ($request->user()) {
|
||||
$this->progress->updateLastLesson($request->user(), $course, $lesson);
|
||||
$this->progress->markCourseCompletedIfFinished($request->user(), $course);
|
||||
}
|
||||
|
||||
$progress = $this->progress->getProgress($request->user(), $course);
|
||||
$previousLesson = $this->navigation->previousLesson($course, $lesson);
|
||||
$nextLesson = $this->navigation->nextLesson($course, $lesson);
|
||||
$courseOutline = $this->navigation->orderedCourseLessons($course)
|
||||
->map(fn (\App\Models\AcademyCourseLesson $entry): array => $this->access->courseLessonPayload($entry, $request->user()))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
|
||||
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
|
||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy course lesson.')), 160, '...');
|
||||
$seo = app(SeoFactory::class)->academyLessonPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title . ' — ' . $course->title)),
|
||||
$description,
|
||||
$canonical,
|
||||
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Courses', 'url' => route('academy.courses.index')],
|
||||
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
|
||||
['name' => (string) $lesson->title, 'url' => $canonical],
|
||||
],
|
||||
array_values((array) ($payload['tags'] ?? [])),
|
||||
$lesson->published_at?->toAtomString(),
|
||||
$lesson->updated_at?->toAtomString(),
|
||||
(string) $course->title,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'lesson',
|
||||
'item' => $payload,
|
||||
'relatedLessons' => [],
|
||||
'relatedCourses' => [],
|
||||
'previousLesson' => $previousLesson ? $this->access->courseLessonPayload($previousLesson, $request->user()) : null,
|
||||
'nextLesson' => $nextLesson ? $this->access->courseLessonPayload($nextLesson, $request->user()) : null,
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
|
||||
'courseContext' => [
|
||||
'id' => (int) $course->id,
|
||||
'title' => (string) $course->title,
|
||||
'slug' => (string) $course->slug,
|
||||
'subtitle' => (string) ($course->subtitle ?? ''),
|
||||
'showUrl' => route('academy.courses.show', ['course' => $course->slug]),
|
||||
'completePayload' => ['course_id' => $course->id],
|
||||
'progress' => [
|
||||
'percent' => (int) ($progress['progress_percent'] ?? 0),
|
||||
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
|
||||
'totalRequired' => (int) ($progress['total_required'] ?? 0),
|
||||
'completed' => (bool) ($progress['completed'] ?? false),
|
||||
],
|
||||
'outline' => $courseOutline,
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Academy/AcademyHomeController.php
Normal file
86
app/Http/Controllers/Academy/AcademyHomeController.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyHomeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCacheService $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$canonical = route('academy.index');
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Skinbase AI Academy — Skinbase',
|
||||
'Learn AI-powered creativity for wallpapers, digital art, skins, news covers, and visual worlds inside Skinbase.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
$seo['og_type'] = 'website';
|
||||
|
||||
$home = $this->cache->homePayload(function (): array {
|
||||
return [
|
||||
'featuredLessons' => $this->cache->featuredLessons(),
|
||||
'featuredCourses' => $this->cache->featuredCourses(),
|
||||
'featuredPrompts' => $this->cache->featuredPrompts(),
|
||||
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
|
||||
? $this->cache->featuredChallenges()
|
||||
: [],
|
||||
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
|
||||
'courseCount' => AcademyCourse::query()->published()->count(),
|
||||
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
|
||||
'challengeCount' => (bool) config('academy.challenges_enabled', true)
|
||||
? AcademyChallenge::query()->publiclyVisible()->count()
|
||||
: 0,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Academy/Index', [
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'links' => [
|
||||
'lessons' => route('academy.lessons.index'),
|
||||
'courses' => route('academy.courses.index'),
|
||||
'prompts' => route('academy.prompts.index'),
|
||||
'packs' => route('academy.packs.index'),
|
||||
'challenges' => route('academy.challenges.index'),
|
||||
],
|
||||
'featureFlags' => [
|
||||
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
|
||||
'challengesEnabled' => (bool) config('academy.challenges_enabled', true),
|
||||
'badgesEnabled' => (bool) config('academy.badges_enabled', true),
|
||||
],
|
||||
'stats' => [
|
||||
'lessonCount' => (int) $home['lessonCount'],
|
||||
'courseCount' => (int) $home['courseCount'],
|
||||
'promptCount' => (int) $home['promptCount'],
|
||||
'challengeCount' => (int) $home['challengeCount'],
|
||||
],
|
||||
'featuredCourses' => collect($home['featuredCourses'])->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))->values()->all(),
|
||||
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
|
||||
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
|
||||
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
164
app/Http/Controllers/Academy/AcademyLessonController.php
Normal file
164
app/Http/Controllers/Academy/AcademyLessonController.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyLessonController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademyAccessService $access,
|
||||
private readonly AcademyCacheService $cache,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
'category' => ['nullable', 'string', 'max:140'],
|
||||
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$query = AcademyLesson::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published()
|
||||
->orderedForCourse();
|
||||
|
||||
if (filled($filters['q'] ?? null)) {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
$builder->where('title', 'like', '%'.$filters['q'].'%')
|
||||
->orWhere('excerpt', 'like', '%'.$filters['q'].'%');
|
||||
});
|
||||
}
|
||||
|
||||
if (filled($filters['category'] ?? null)) {
|
||||
$query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category']));
|
||||
}
|
||||
|
||||
if (filled($filters['difficulty'] ?? null)) {
|
||||
$query->where('difficulty', $filters['difficulty']);
|
||||
}
|
||||
|
||||
$lessons = $query->paginate(12)->withQueryString();
|
||||
$lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()));
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionListing(
|
||||
'Academy Lessons — Skinbase',
|
||||
'Browse Skinbase AI Academy lessons covering prompting, workflow cleanup, ethics, and upload-ready creative workflows.',
|
||||
route('academy.lessons.index', $request->query()),
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'lessons',
|
||||
'title' => 'Academy lessons',
|
||||
'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.',
|
||||
'seo' => $seo,
|
||||
'items' => $lessons,
|
||||
'filters' => $filters,
|
||||
'categories' => $this->cache->categoriesByType('lesson'),
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$lesson = AcademyLesson::query()
|
||||
->with(['category', 'activeBlocks.activeComparisonResults'])
|
||||
->active()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
|
||||
$courseQuery = AcademyLesson::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published();
|
||||
|
||||
if (filled($lesson->series_name)) {
|
||||
$courseQuery->where('series_name', $lesson->series_name);
|
||||
} elseif ($lesson->category_id !== null) {
|
||||
$courseQuery->where('category_id', $lesson->category_id);
|
||||
} else {
|
||||
$courseQuery->whereKey($lesson->id);
|
||||
}
|
||||
|
||||
$courseLessons = $courseQuery
|
||||
->orderedForCourse()
|
||||
->get()
|
||||
->filter(fn (AcademyLesson $courseLesson): bool => $this->access->canAccessLesson($request->user(), $courseLesson))
|
||||
->values();
|
||||
|
||||
$currentIndex = $courseLessons->search(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson));
|
||||
$previousLesson = is_int($currentIndex) && $currentIndex > 0
|
||||
? $courseLessons->get($currentIndex - 1)
|
||||
: null;
|
||||
$nextLesson = is_int($currentIndex) && $currentIndex < ($courseLessons->count() - 1)
|
||||
? $courseLessons->get($currentIndex + 1)
|
||||
: null;
|
||||
|
||||
$relatedLessons = $courseLessons
|
||||
->reject(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson))
|
||||
->take(6)
|
||||
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
|
||||
->values()
|
||||
->all();
|
||||
$relatedCourses = AcademyCourse::query()
|
||||
->published()
|
||||
->ordered()
|
||||
->whereHas('courseLessons', fn ($builder) => $builder->where('lesson_id', $lesson->id))
|
||||
->limit(3)
|
||||
->get()
|
||||
->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))
|
||||
->values()
|
||||
->all();
|
||||
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
|
||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
|
||||
$seo = app(SeoFactory::class)->academyLessonPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
|
||||
$description,
|
||||
$canonical,
|
||||
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
|
||||
[
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Lessons', 'url' => route('academy.lessons.index')],
|
||||
['name' => (string) $lesson->title, 'url' => $canonical],
|
||||
],
|
||||
array_values((array) ($payload['tags'] ?? [])),
|
||||
$lesson->published_at?->toAtomString(),
|
||||
$lesson->updated_at?->toAtomString(),
|
||||
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/Show', [
|
||||
'pageType' => 'lesson',
|
||||
'item' => $payload,
|
||||
'relatedLessons' => $relatedLessons,
|
||||
'relatedCourses' => $relatedCourses,
|
||||
'previousLesson' => $previousLesson ? $this->access->lessonPayload($previousLesson, $request->user()) : null,
|
||||
'nextLesson' => $nextLesson ? $this->access->lessonPayload($nextLesson, $request->user()) : null,
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
|
||||
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/Academy/AcademyPricingController.php
Normal file
72
app/Http/Controllers/Academy/AcademyPricingController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Academy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AcademyPricingController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$canonical = route('academy.pricing');
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionPage(
|
||||
'Skinbase AI Academy Pricing — Skinbase',
|
||||
'Compare Skinbase AI Academy Explorer, Creator, and Pro plans and preview premium access before payments go live.',
|
||||
$canonical,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
$seo['og_type'] = 'website';
|
||||
|
||||
return Inertia::render('Academy/Pricing', [
|
||||
'seo' => $seo,
|
||||
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
|
||||
'plans' => [
|
||||
[
|
||||
'name' => 'Explorer',
|
||||
'price' => '€0',
|
||||
'interval' => '/ month',
|
||||
'badge' => 'Free',
|
||||
'features' => [
|
||||
'Beginner AI lessons',
|
||||
'Basic prompting tutorials',
|
||||
'Public prompt previews',
|
||||
'Public Academy challenges',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Creator',
|
||||
'price' => '€4.99',
|
||||
'interval' => '/ month',
|
||||
'badge' => 'Premium',
|
||||
'features' => [
|
||||
'Full prompt library',
|
||||
'Premium lessons',
|
||||
'Prompt packs',
|
||||
'Saved prompt collections',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Pro',
|
||||
'price' => '€9.99',
|
||||
'interval' => '/ month',
|
||||
'badge' => 'Advanced',
|
||||
'features' => [
|
||||
'Advanced AI workflows',
|
||||
'World Builder kits',
|
||||
'Pro challenges',
|
||||
'Profile highlight badge',
|
||||
],
|
||||
],
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user