Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -37,8 +37,12 @@ class PostSearchController extends Controller
->where('status', Post::STATUS_PUBLISHED)
->paginate($perPage, 'page', $page);
// Load relations
$results->load($this->feedService->publicEagerLoads());
if ($viewerId) {
$results->getCollection()->loadExists([
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
]);
}
$formatted = $results->getCollection()
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
@@ -57,6 +61,9 @@ class PostSearchController extends Controller
} catch (\Exception $e) {
// Fallback: basic LIKE search on body
$paginated = Post::with($this->feedService->publicEagerLoads())
->withExists([
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
])
->where('status', Post::STATUS_PUBLISHED)
->where('visibility', Post::VISIBILITY_PUBLIC)
->where(function ($q) use ($query) {

View File

@@ -10,6 +10,7 @@ use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -48,19 +49,15 @@ final class ProfileApiController extends Controller
},
])
->where('user_id', $user->id)
->whereNull('deleted_at');
->whereNull('artworks.deleted_at');
if (! $isOwner) {
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
$query->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at');
}
$query = match ($sort) {
'trending' => $query->orderByDesc('ranking_score'),
'rising' => $query->orderByDesc('heat_score'),
'views' => $query->orderByDesc('view_count'),
'favs' => $query->orderByDesc('favourite_count'),
default => $query->orderByDesc('published_at'),
};
$query = $this->applyArtworkSort($query, $sort);
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
@@ -185,6 +182,30 @@ final class ProfileApiController extends Controller
return null;
}
private function applyArtworkSort(Builder $query, string $sort): Builder
{
$statsColumn = match ($sort) {
'trending' => 'profile_artwork_stats.ranking_score',
'rising' => 'profile_artwork_stats.heat_score',
'views' => 'profile_artwork_stats.views',
'favs' => 'profile_artwork_stats.favorites',
default => null,
};
if ($statsColumn !== null) {
return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->orderByDesc($statsColumn)
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
}
return $query
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
}
/**
* @return array<string, mixed>
*/

View File

@@ -117,7 +117,7 @@ final class SimilarArtworksController extends Controller
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")',
$categorySlugs
));
$filterParts[] = '(' . $catFilter . ')';

View File

@@ -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;
@@ -82,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');
@@ -97,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',
@@ -116,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
);
}
}
@@ -150,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[] = [
@@ -172,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,
@@ -182,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';
}
@@ -198,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;
});
@@ -224,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);
@@ -588,6 +632,7 @@ final class UploadController extends Controller
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
$mode = $validated['mode'] ?? 'now';
@@ -676,14 +721,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,
@@ -704,18 +742,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 {
@@ -784,6 +811,7 @@ final class UploadController extends Controller
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
if (! ctype_digit($id)) {