Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -25,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>
|
||||
|
||||
@@ -372,26 +372,18 @@
|
||||
<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
|
||||
</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
|
||||
<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>
|
||||
@@ -489,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);
|
||||
@@ -506,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>
|
||||
@@ -535,7 +531,6 @@
|
||||
<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>
|
||||
@if($skinbaseToolbarCanAuth)
|
||||
<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>
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user