Wire admin studio SSR and search infrastructure
This commit is contained in:
160
app/Http/Controllers/Admin/AdminController.php
Normal file
160
app/Http/Controllers/Admin/AdminController.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AdminController extends Controller
|
||||
{
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────
|
||||
|
||||
public function dashboard(): Response
|
||||
{
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'new_users_today' => User::whereDate('created_at', today())->count(),
|
||||
'staff_count' => User::whereIn('role', ['admin', 'manager', 'editorial'])->count(),
|
||||
'moderator_count' => User::where('role', 'moderator')->count(),
|
||||
];
|
||||
|
||||
return Inertia::render('Admin/Dashboard', [
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function users(Request $request): Response
|
||||
{
|
||||
$search = $request->string('search')->trim()->toString();
|
||||
$roleFilter = $request->string('role')->trim()->toString();
|
||||
|
||||
$query = User::select('id', 'name', 'username', 'email', 'role', 'created_at', 'is_active')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($search !== '') {
|
||||
$query->where(function ($q) use ($search): void {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($roleFilter !== '' && $roleFilter !== 'all') {
|
||||
$query->where('role', $roleFilter);
|
||||
}
|
||||
|
||||
$users = $query->paginate(50)->withQueryString();
|
||||
|
||||
return Inertia::render('Admin/Users/Index', [
|
||||
'users' => $users,
|
||||
'filters' => ['search' => $search, 'role' => $roleFilter],
|
||||
'roles' => collect(UserRole::cases())->map(fn ($r) => [
|
||||
'value' => $r->value,
|
||||
'label' => $r->label(),
|
||||
'badge' => $r->badgeClass(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Promote / Demote ──────────────────────────────────────────────────────
|
||||
|
||||
public function updateRole(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'role' => ['required', 'string', 'in:' . implode(',', array_column(UserRole::cases(), 'value'))],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $actor */
|
||||
$actor = $request->user();
|
||||
|
||||
// Only admins can set the 'admin' role.
|
||||
if ($request->input('role') === UserRole::Admin->value && ! $actor->isAdmin()) {
|
||||
abort(403, 'Only admins can grant the Admin role.');
|
||||
}
|
||||
|
||||
// Prevent self-demotion.
|
||||
if ($actor->id === $user->id) {
|
||||
return back()->with('error', 'You cannot change your own role.');
|
||||
}
|
||||
|
||||
$user->update(['role' => $request->input('role')]);
|
||||
|
||||
return back()->with('success', "Role updated to \"{$request->input('role')}\" for {$user->name}.");
|
||||
}
|
||||
|
||||
// ── Stories ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function stories(Request $request): Response
|
||||
{
|
||||
$stories = Story::with('creator:id,name,username')
|
||||
->select('id', 'title', 'status', 'published_at', 'creator_id')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('Admin/Stories', [
|
||||
'stories' => $stories,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Artworks ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function artworks(Request $request): Response
|
||||
{
|
||||
$artworks = Artwork::with('user:id,name,username')
|
||||
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
// Normalise status field and add thumb URL
|
||||
$artworks->getCollection()->transform(function ($artwork) {
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'status' => $artwork->artwork_status,
|
||||
'thumb' => $artwork->thumbUrl('sm') ?? null,
|
||||
'created_at' => $artwork->created_at,
|
||||
'user' => $artwork->user,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Admin/Artworks', [
|
||||
'artworks' => $artworks,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Username Queue ────────────────────────────────────────────────────────
|
||||
|
||||
public function usernameQueue(): Response
|
||||
{
|
||||
return Inertia::render('Admin/UsernameQueue');
|
||||
}
|
||||
|
||||
// ── Upload Queue ──────────────────────────────────────────────────────────
|
||||
|
||||
public function uploadQueue(): Response
|
||||
{
|
||||
return Inertia::render('Admin/UploadQueue');
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function settings(): Response
|
||||
{
|
||||
return Inertia::render('Admin/Settings', [
|
||||
'settings' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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 . ')';
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -6,8 +6,10 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -38,16 +40,18 @@ final class ArtworkDownloadController extends Controller
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkStatsService $stats,
|
||||
private readonly ArtworkOriginalFileLocator $originalFiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse|Response
|
||||
{
|
||||
$artwork = Artwork::query()->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$filePath = $this->resolveOriginalPath($artwork);
|
||||
$filePath = $this->originalFiles->resolveLocalPath($artwork);
|
||||
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
|
||||
|
||||
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
@@ -76,36 +80,59 @@ final class ArtworkDownloadController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
|
||||
// X-Accel-Redirect is safe only when nginx is explicitly configured to
|
||||
// map the internal URI to the originals root. Otherwise fallback to the
|
||||
// normal Laravel download response.
|
||||
$accelUri = $this->resolveAccelUri($filePath);
|
||||
if ($accelUri !== null) {
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => $accelUri,
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . addslashes($downloadName) . '"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->download($filePath, $downloadName);
|
||||
}
|
||||
|
||||
private function resolveOriginalPath(Artwork $artwork): string
|
||||
private function resolveAccelUri(string $filePath): ?string
|
||||
{
|
||||
$relative = trim((string) $artwork->file_path, '/');
|
||||
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
|
||||
|
||||
if ($relative !== '' && str_starts_with($relative, $prefix)) {
|
||||
$suffix = substr($relative, strlen($prefix));
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
|
||||
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
|
||||
if (! config('app.download_accel_enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
|
||||
if (! $this->isValidHash($hash) || $ext === '') {
|
||||
return '';
|
||||
$accelBase = rtrim((string) config('app.download_accel_path', ''), '/');
|
||||
if ($accelBase === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
if ($root === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
|
||||
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
|
||||
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
|
||||
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
|
||||
$rootPrefix = $normalizedRoot . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
|
||||
Log::warning('Artwork download accel path skipped because file is outside originals root.', [
|
||||
'resolved_path' => $filePath,
|
||||
'originals_root' => $root,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$relativePath = substr($normalizedFilePath, strlen($normalizedRoot));
|
||||
if ($relativePath === false || $relativePath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $accelBase . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
|
||||
}
|
||||
|
||||
private function recordDownload(Request $request, int $artworkId): void
|
||||
@@ -139,11 +166,6 @@ final class ArtworkDownloadController extends Controller
|
||||
Artwork::query()->whereKey($artworkId)->increment('download_count');
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
|
||||
private function buildDownloadFilename(string $fileName, string $ext): string
|
||||
{
|
||||
$name = trim($fileName);
|
||||
|
||||
@@ -2,200 +2,23 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\CategoryDirectoryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CategoryDirectoryService $directory,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
$sort = (string) $request->query('sort', 'popular');
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = min(60, max(12, (int) $request->query('per_page', 24)));
|
||||
|
||||
$categories = collect(Cache::remember('categories.directory.v1', 3600, function (): array {
|
||||
$publishedArtworkScope = DB::table('artwork_category as artwork_category')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
|
||||
->leftJoin('artwork_stats as artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereColumn('artwork_category.category_id', 'categories.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at');
|
||||
|
||||
$categories = Category::query()
|
||||
->select([
|
||||
'categories.id',
|
||||
'categories.content_type_id',
|
||||
'categories.parent_id',
|
||||
'categories.name',
|
||||
'categories.slug',
|
||||
])
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)->selectRaw('COUNT(DISTINCT artworks.id)'),
|
||||
'artwork_count'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->whereNotNull('artworks.hash')
|
||||
->whereNotNull('artworks.thumb_ext')
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(1)
|
||||
->select('artworks.hash'),
|
||||
'cover_hash'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->whereNotNull('artworks.hash')
|
||||
->whereNotNull('artworks.thumb_ext')
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(1)
|
||||
->select('artworks.thumb_ext'),
|
||||
'cover_ext'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->selectRaw('COALESCE(SUM(COALESCE(artwork_stats.views, 0) + (COALESCE(artwork_stats.favorites, 0) * 3) + (COALESCE(artwork_stats.downloads, 0) * 2)), 0)'),
|
||||
'popular_score'
|
||||
)
|
||||
->with(['contentType:id,name,slug'])
|
||||
->active()
|
||||
->orderBy('categories.name')
|
||||
->get();
|
||||
|
||||
return $this->transformCategories($categories);
|
||||
}));
|
||||
|
||||
$filtered = $this->filterAndSortCategories($categories, $search, $sort);
|
||||
$total = $filtered->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$currentPage = min($page, $lastPage);
|
||||
$offset = ($currentPage - 1) * $perPage;
|
||||
$pageItems = $filtered->slice($offset, $perPage)->values();
|
||||
$popularCategories = $this->filterAndSortCategories($categories, '', 'popular')->take(4)->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $pageItems,
|
||||
'meta' => [
|
||||
'current_page' => $currentPage,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'summary' => [
|
||||
'total_categories' => $categories->count(),
|
||||
'total_artworks' => $categories->sum(fn (array $category): int => (int) ($category['artwork_count'] ?? 0)),
|
||||
],
|
||||
'popular_categories' => $search === '' ? $popularCategories : [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $categories
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterAndSortCategories(Collection $categories, string $search, string $sort): Collection
|
||||
{
|
||||
$filtered = $categories;
|
||||
|
||||
if ($search !== '') {
|
||||
$needle = mb_strtolower($search);
|
||||
|
||||
$filtered = $filtered->filter(function (array $category) use ($needle): bool {
|
||||
return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle);
|
||||
});
|
||||
}
|
||||
|
||||
return $filtered->sort(function (array $left, array $right) use ($sort): int {
|
||||
if ($sort === 'az') {
|
||||
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
if ($sort === 'artworks') {
|
||||
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
|
||||
|
||||
return $countCompare !== 0
|
||||
? $countCompare
|
||||
: strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
$scoreCompare = ((int) ($right['popular_score'] ?? 0)) <=> ((int) ($left['popular_score'] ?? 0));
|
||||
if ($scoreCompare !== 0) {
|
||||
return $scoreCompare;
|
||||
}
|
||||
|
||||
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
|
||||
if ($countCompare !== 0) {
|
||||
return $countCompare;
|
||||
}
|
||||
|
||||
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Category> $categories
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function transformCategories(Collection $categories): array
|
||||
{
|
||||
$categoryMap = $categories->keyBy('id');
|
||||
$pathCache = [];
|
||||
|
||||
$buildPath = function (Category $category) use (&$buildPath, &$pathCache, $categoryMap): string {
|
||||
if (isset($pathCache[$category->id])) {
|
||||
return $pathCache[$category->id];
|
||||
}
|
||||
|
||||
if ($category->parent_id && $categoryMap->has($category->parent_id)) {
|
||||
$pathCache[$category->id] = $buildPath($categoryMap->get($category->parent_id)) . '/' . $category->slug;
|
||||
|
||||
return $pathCache[$category->id];
|
||||
}
|
||||
|
||||
$pathCache[$category->id] = $category->slug;
|
||||
|
||||
return $pathCache[$category->id];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->map(function (Category $category) use ($buildPath): array {
|
||||
$contentTypeSlug = strtolower((string) ($category->contentType?->slug ?? 'categories'));
|
||||
$path = $buildPath($category);
|
||||
$coverImage = null;
|
||||
|
||||
if (! empty($category->cover_hash) && ! empty($category->cover_ext)) {
|
||||
$coverImage = ThumbnailService::fromHash((string) $category->cover_hash, (string) $category->cover_ext, 'md');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'name' => (string) $category->name,
|
||||
'slug' => (string) $category->slug,
|
||||
'url' => '/' . $contentTypeSlug . '/' . $path,
|
||||
'content_type' => [
|
||||
'name' => (string) ($category->contentType?->name ?? 'Categories'),
|
||||
'slug' => $contentTypeSlug,
|
||||
],
|
||||
'cover_image' => $coverImage ?: 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'artwork_count' => (int) ($category->artwork_count ?? 0),
|
||||
'popular_score' => (int) ($category->popular_score ?? 0),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
return response()->json($this->directory->getDirectoryPayload(
|
||||
(string) $request->query('q', ''),
|
||||
(string) $request->query('sort', 'popular'),
|
||||
(int) $request->query('page', 1),
|
||||
(int) $request->query('per_page', 24),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class FavoriteController extends Controller
|
||||
if ($slice !== []) {
|
||||
$arts = Artwork::query()
|
||||
->whereIn('id', $slice)
|
||||
->with(['user.profile', 'categories'])
|
||||
->with(['user.profile', 'categories.contentType'])
|
||||
->withCount(['favourites', 'comments'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
116
app/Http/Controllers/Legacy/LegacyArtworkPhotoController.php
Normal file
116
app/Http/Controllers/Legacy/LegacyArtworkPhotoController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class LegacyArtworkPhotoController extends Controller
|
||||
{
|
||||
private const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
private const THUMB_SIZE_MAP = [
|
||||
0 => 'xs',
|
||||
1 => 'xs',
|
||||
2 => 'xs',
|
||||
3 => 'sm',
|
||||
4 => 'sm',
|
||||
5 => 'sm',
|
||||
6 => 'md',
|
||||
];
|
||||
|
||||
private static ?bool $hasLegacyIdColumn = null;
|
||||
|
||||
public function __invoke(string $encoded, string $size, string $extension): RedirectResponse
|
||||
{
|
||||
$artworkId = $this->decodeBase62($encoded);
|
||||
$sizeCode = (int) $size;
|
||||
|
||||
abort_if($artworkId === null || $artworkId < 1, 404);
|
||||
|
||||
$artwork = $this->resolveArtwork($artworkId);
|
||||
abort_unless($artwork !== null, 404);
|
||||
|
||||
$targetUrl = $sizeCode === 7
|
||||
? $this->resolveOriginalUrl($artwork)
|
||||
: $artwork->thumbUrl(self::THUMB_SIZE_MAP[$sizeCode] ?? 'md');
|
||||
|
||||
abort_if(empty($targetUrl), 404);
|
||||
|
||||
return redirect()->away($targetUrl, 301);
|
||||
}
|
||||
|
||||
private function decodeBase62(string $value): ?int
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$alphabet = array_flip(str_split(self::BASE62_CHARS));
|
||||
$decoded = 0;
|
||||
|
||||
foreach (str_split($value) as $character) {
|
||||
if (! array_key_exists($character, $alphabet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = ($decoded * 62) + $alphabet[$character];
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function resolveArtwork(int $artworkId): ?Artwork
|
||||
{
|
||||
return Artwork::query()
|
||||
->select(['id', 'hash', 'thumb_ext', 'file_ext', 'file_path', 'is_public', 'is_approved', 'published_at'])
|
||||
->where(function (Builder $query) use ($artworkId): void {
|
||||
$query->where('id', $artworkId);
|
||||
|
||||
if ($this->hasLegacyIdColumn()) {
|
||||
$query->orWhere('legacy_id', $artworkId);
|
||||
}
|
||||
})
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveOriginalUrl(Artwork $artwork): ?string
|
||||
{
|
||||
$cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/');
|
||||
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
|
||||
|
||||
if ($filePath !== '') {
|
||||
return $cdn . '/' . $filePath;
|
||||
}
|
||||
|
||||
$hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? '')));
|
||||
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
|
||||
|
||||
if ($hash === '') {
|
||||
return $artwork->thumbUrl('xl') ?? $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md');
|
||||
}
|
||||
|
||||
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
|
||||
$firstDir = substr($hash, 0, 2);
|
||||
$secondDir = substr($hash, 2, 2);
|
||||
|
||||
return sprintf('%s/%s/original/%s/%s/%s.%s', $cdn, $prefix, $firstDir, $secondDir, $hash, $ext);
|
||||
}
|
||||
|
||||
private function hasLegacyIdColumn(): bool
|
||||
{
|
||||
if (self::$hasLegacyIdColumn === null) {
|
||||
self::$hasLegacyIdColumn = Schema::hasColumn('artworks', 'legacy_id');
|
||||
}
|
||||
|
||||
return self::$hasLegacyIdColumn;
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,7 @@ final class DiscoverFeedController extends Controller
|
||||
$artworks = Cache::remember('rss:discover:trending', 600, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.trending_score_7d')
|
||||
->orderByDesc('artworks.trending_score_7d')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
|
||||
@@ -86,8 +86,7 @@ final class ExploreFeedController extends Controller
|
||||
|
||||
return match ($mode) {
|
||||
'trending' => $query
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.trending_score_7d')
|
||||
->orderByDesc('artworks.trending_score_7d')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
|
||||
@@ -26,6 +26,9 @@ final class AiBiographyAdminController extends Controller
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$isAdminSurface = $request->routeIs('admin.cp.ai-biography.*');
|
||||
$routePrefix = $isAdminSurface ? 'admin.cp.ai-biography.' : 'cp.ai-biography.';
|
||||
|
||||
$filters = $this->filters($request);
|
||||
|
||||
$records = $this->recordsQuery($filters)
|
||||
@@ -33,7 +36,7 @@ final class AiBiographyAdminController extends Controller
|
||||
->withQueryString()
|
||||
->through(fn (CreatorAiBiography $record): array => $this->mapRecord($record));
|
||||
|
||||
return Inertia::render('Moderation/AiBiographyAdmin', [
|
||||
return Inertia::render($isAdminSurface ? 'Admin/AiBiography' : 'Moderation/AiBiographyAdmin', [
|
||||
'title' => 'AI Biography Review',
|
||||
'records' => $records,
|
||||
'filters' => $filters,
|
||||
@@ -72,14 +75,14 @@ final class AiBiographyAdminController extends Controller
|
||||
],
|
||||
],
|
||||
'endpoints' => [
|
||||
'index' => route('cp.ai-biography.index'),
|
||||
'rebuildPattern' => route('cp.ai-biography.rebuild', ['user' => '__USER__']),
|
||||
'approvePattern' => route('cp.ai-biography.approve', ['biography' => '__BIOGRAPHY__']),
|
||||
'flagPattern' => route('cp.ai-biography.flag', ['biography' => '__BIOGRAPHY__']),
|
||||
'hidePattern' => route('cp.ai-biography.hide', ['biography' => '__BIOGRAPHY__']),
|
||||
'showPattern' => route('cp.ai-biography.show', ['biography' => '__BIOGRAPHY__']),
|
||||
'index' => route($routePrefix . 'index'),
|
||||
'rebuildPattern' => route($routePrefix . 'rebuild', ['user' => '__USER__']),
|
||||
'approvePattern' => route($routePrefix . 'approve', ['biography' => '__BIOGRAPHY__']),
|
||||
'flagPattern' => route($routePrefix . 'flag', ['biography' => '__BIOGRAPHY__']),
|
||||
'hidePattern' => route($routePrefix . 'hide', ['biography' => '__BIOGRAPHY__']),
|
||||
'showPattern' => route($routePrefix . 'show', ['biography' => '__BIOGRAPHY__']),
|
||||
],
|
||||
])->rootView('moderation');
|
||||
])->rootView($isAdminSurface ? 'admin' : 'moderation');
|
||||
}
|
||||
|
||||
public function rebuild(User $user): JsonResponse
|
||||
|
||||
@@ -23,18 +23,21 @@ class FeaturedArtworkAdminController extends Controller
|
||||
{
|
||||
}
|
||||
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Collection/FeaturedArtworksAdmin', array_merge(
|
||||
$isAdminSurface = $request->routeIs('admin.artworks.featured.*');
|
||||
$routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.';
|
||||
|
||||
return Inertia::render($isAdminSurface ? 'Admin/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin', array_merge(
|
||||
$this->featuredArtworks->pageProps(),
|
||||
[
|
||||
'endpoints' => [
|
||||
'search' => route('admin.cp.artworks.featured.search'),
|
||||
'store' => route('admin.cp.artworks.featured.store'),
|
||||
'updatePattern' => route('admin.cp.artworks.featured.update', ['feature' => '__FEATURE__']),
|
||||
'togglePattern' => route('admin.cp.artworks.featured.toggle', ['feature' => '__FEATURE__']),
|
||||
'forceHeroPattern' => route('admin.cp.artworks.featured.force-hero', ['feature' => '__FEATURE__']),
|
||||
'destroyPattern' => route('admin.cp.artworks.featured.delete', ['feature' => '__FEATURE__']),
|
||||
'search' => route($routePrefix . 'search'),
|
||||
'store' => route($routePrefix . 'store'),
|
||||
'updatePattern' => route($routePrefix . 'update', ['feature' => '__FEATURE__']),
|
||||
'togglePattern' => route($routePrefix . 'toggle', ['feature' => '__FEATURE__']),
|
||||
'forceHeroPattern' => route($routePrefix . 'force-hero', ['feature' => '__FEATURE__']),
|
||||
'destroyPattern' => route($routePrefix . 'delete', ['feature' => '__FEATURE__']),
|
||||
],
|
||||
'capabilities' => [
|
||||
'forceHeroEnabled' => $this->hasForceHeroColumn(),
|
||||
@@ -42,11 +45,11 @@ class FeaturedArtworkAdminController extends Controller
|
||||
'seo' => [
|
||||
'title' => 'Featured Artworks — Skinbase Nova',
|
||||
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
|
||||
'canonical' => route('admin.cp.artworks.featured.main'),
|
||||
'canonical' => route($routePrefix . 'main'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
],
|
||||
))->rootView('collections');
|
||||
))->rootView($isAdminSurface ? 'admin' : 'collections');
|
||||
}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
|
||||
@@ -4,58 +4,61 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use App\Services\Sitemaps\PublishedSitemapResolver;
|
||||
use App\Services\Sitemaps\SitemapXmlRenderer;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class SitemapController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly PublishedSitemapResolver $published,
|
||||
private readonly SitemapXmlRenderer $renderer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): Response
|
||||
public function index(): Response|BinaryFileResponse
|
||||
{
|
||||
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
|
||||
$published = $this->published->resolveIndex();
|
||||
if ($published !== null) {
|
||||
return $this->renderer->xmlResponse($published['content']);
|
||||
}
|
||||
// 1. Static file written by the build/generate commands.
|
||||
// On production nginx serves this directly via try_files without reaching PHP.
|
||||
// On dev / misconfigured servers we stream it with sendfile — no RAM load.
|
||||
$path = public_path('sitemap.xml');
|
||||
if (file_exists($path)) {
|
||||
return $this->xmlFileResponse($path);
|
||||
}
|
||||
|
||||
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
|
||||
// 2. Published release (release management pipeline fallback).
|
||||
$published = $this->published->resolveIndex();
|
||||
if ($published !== null) {
|
||||
return $this->renderer->xmlResponse($published['content']);
|
||||
}
|
||||
|
||||
$built = $this->build->buildIndex(
|
||||
force: false,
|
||||
persist: (bool) config('sitemaps.refresh.build_on_request', true),
|
||||
);
|
||||
|
||||
return $this->renderer->xmlResponse($built['content']);
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
public function show(string $name): Response
|
||||
public function show(string $name): Response|BinaryFileResponse
|
||||
{
|
||||
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
|
||||
$published = $this->published->resolveNamed($name);
|
||||
if ($published !== null) {
|
||||
return $this->renderer->xmlResponse($published['content']);
|
||||
}
|
||||
// 1. Static file.
|
||||
$path = public_path('sitemaps/' . $name . '.xml');
|
||||
if (file_exists($path)) {
|
||||
return $this->xmlFileResponse($path);
|
||||
}
|
||||
|
||||
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
|
||||
// 2. Published release.
|
||||
$published = $this->published->resolveNamed($name);
|
||||
if ($published !== null) {
|
||||
return $this->renderer->xmlResponse($published['content']);
|
||||
}
|
||||
|
||||
$built = $this->build->buildNamed(
|
||||
$name,
|
||||
force: false,
|
||||
persist: (bool) config('sitemaps.refresh.build_on_request', true),
|
||||
);
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
abort_if($built === null, 404);
|
||||
|
||||
return $this->renderer->xmlResponse($built['content']);
|
||||
private function xmlFileResponse(string $absolutePath): BinaryFileResponse
|
||||
{
|
||||
return response()->file($absolutePath, [
|
||||
'Content-Type' => 'application/xml; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=' . max(60, (int) config('sitemaps.cache_ttl_seconds', 900)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ class StoryController extends Controller
|
||||
'scheduled_for' => $resolved['scheduled_for'],
|
||||
'meta_title' => $validated['meta_title'] ?? $validated['title'],
|
||||
'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160),
|
||||
'canonical_url' => $validated['canonical_url'] ?? null,
|
||||
'canonical_url' => null,
|
||||
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
|
||||
'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? now() : null,
|
||||
]);
|
||||
@@ -244,7 +244,7 @@ class StoryController extends Controller
|
||||
->with('status', 'Story published.');
|
||||
}
|
||||
|
||||
return redirect()->route('creator.stories.edit', ['story' => $story->id])
|
||||
return redirect()->route('studio.stories.edit', ['story' => $story->id])
|
||||
->with('status', $resolved['status'] === 'pending_review' ? 'Story submitted for review.' : 'Draft saved.');
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ class StoryController extends Controller
|
||||
'scheduled_for' => $resolved['scheduled_for'],
|
||||
'meta_title' => $validated['meta_title'] ?? $validated['title'],
|
||||
'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160),
|
||||
'canonical_url' => $validated['canonical_url'] ?? null,
|
||||
'canonical_url' => null,
|
||||
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
|
||||
'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
|
||||
]);
|
||||
@@ -499,7 +499,6 @@ class StoryController extends Controller
|
||||
'tags_csv' => ['nullable', 'string', 'max:500'],
|
||||
'meta_title' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['nullable', 'string', 'max:300'],
|
||||
'canonical_url' => ['nullable', 'url', 'max:500'],
|
||||
'og_image' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
@@ -532,7 +531,7 @@ class StoryController extends Controller
|
||||
'scheduled_for' => $workflow['scheduled_for'],
|
||||
'meta_title' => $validated['meta_title'] ?? $title,
|
||||
'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160),
|
||||
'canonical_url' => $validated['canonical_url'] ?? null,
|
||||
'canonical_url' => null,
|
||||
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
|
||||
'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? now() : null,
|
||||
]);
|
||||
@@ -571,7 +570,6 @@ class StoryController extends Controller
|
||||
'tags_csv' => ['nullable', 'string', 'max:500'],
|
||||
'meta_title' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['nullable', 'string', 'max:300'],
|
||||
'canonical_url' => ['nullable', 'url', 'max:500'],
|
||||
'og_image' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
@@ -605,7 +603,7 @@ class StoryController extends Controller
|
||||
'scheduled_for' => $workflow['scheduled_for'],
|
||||
'meta_title' => $validated['meta_title'] ?? $story->meta_title ?? $title,
|
||||
'meta_description' => $validated['meta_description'] ?? $story->meta_description,
|
||||
'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url,
|
||||
'canonical_url' => null,
|
||||
'og_image' => $validated['og_image'] ?? $story->og_image,
|
||||
'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
|
||||
]);
|
||||
@@ -642,7 +640,6 @@ class StoryController extends Controller
|
||||
'tags_csv' => ['nullable', 'string', 'max:500'],
|
||||
'meta_title' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['nullable', 'string', 'max:300'],
|
||||
'canonical_url' => ['nullable', 'url', 'max:500'],
|
||||
'og_image' => ['nullable', 'string', 'max:500'],
|
||||
'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
|
||||
'scheduled_for' => ['nullable', 'date'],
|
||||
@@ -673,7 +670,7 @@ class StoryController extends Controller
|
||||
'status' => 'draft',
|
||||
'meta_title' => $validated['meta_title'] ?? $title,
|
||||
'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160),
|
||||
'canonical_url' => $validated['canonical_url'] ?? null,
|
||||
'canonical_url' => null,
|
||||
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
|
||||
]);
|
||||
} else {
|
||||
@@ -697,7 +694,7 @@ class StoryController extends Controller
|
||||
'status' => $nextStatus,
|
||||
'meta_title' => $validated['meta_title'] ?? $story->meta_title,
|
||||
'meta_description' => $validated['meta_description'] ?? $story->meta_description,
|
||||
'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url,
|
||||
'canonical_url' => null,
|
||||
'og_image' => $validated['og_image'] ?? $story->og_image,
|
||||
'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
|
||||
]);
|
||||
@@ -897,7 +894,6 @@ class StoryController extends Controller
|
||||
'tags_csv' => ['nullable', 'string', 'max:500'],
|
||||
'meta_title' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['nullable', 'string', 'max:300'],
|
||||
'canonical_url' => ['nullable', 'url', 'max:500'],
|
||||
'og_image' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ final class StudioArtworksApiController extends Controller
|
||||
'world_submissions' => 'sometimes|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',
|
||||
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
|
||||
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
|
||||
'evolution_note' => 'sometimes|nullable|string|max:1200',
|
||||
@@ -284,16 +285,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']);
|
||||
|
||||
@@ -124,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)
|
||||
*/
|
||||
|
||||
231
app/Http/Controllers/Studio/StudioNewsMediaApiController.php
Normal file
231
app/Http/Controllers/Studio/StudioNewsMediaApiController.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class StudioNewsMediaApiController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
private const MAX_FILE_SIZE_KB = 6144;
|
||||
|
||||
private const MAX_WIDTH = 2200;
|
||||
|
||||
private const MAX_HEIGHT = 1400;
|
||||
|
||||
private const MIN_WIDTH = 1200;
|
||||
|
||||
private const MIN_HEIGHT = 630;
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'image' => [
|
||||
'required',
|
||||
'file',
|
||||
'image',
|
||||
'max:' . self::MAX_FILE_SIZE_KB,
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'mimetypes:image/jpeg,image/png,image/webp',
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
|
||||
try {
|
||||
$stored = $this->storeMediaFile($file);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $stored['path'],
|
||||
'url' => $this->publicUrlForPath($stored['path']),
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime_type' => 'image/webp',
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Validation failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('News media upload failed', [
|
||||
'user_id' => (int) ($request->user()?->id ?? 0),
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Upload failed',
|
||||
'message' => 'Could not upload image right now.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$this->deleteMediaFile((string) $validated['path']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path:string,width:int,height:int,size_bytes:int}
|
||||
*/
|
||||
private function storeMediaFile(UploadedFile $file): array
|
||||
{
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($uploadPath === '' || ! is_readable($uploadPath)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded image path.');
|
||||
}
|
||||
|
||||
$raw = file_get_contents($uploadPath);
|
||||
if ($raw === false || $raw === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image.');
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($raw));
|
||||
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported image mime type.');
|
||||
}
|
||||
|
||||
$size = @getimagesizefromstring($raw);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
|
||||
$width = (int) ($size[0] ?? 0);
|
||||
$height = (int) ($size[1] ?? 0);
|
||||
|
||||
if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Image is too small. Minimum required size is %dx%d.',
|
||||
self::MIN_WIDTH,
|
||||
self::MIN_HEIGHT,
|
||||
));
|
||||
}
|
||||
|
||||
$image = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT);
|
||||
$encoded = (string) $image->encode(new WebpEncoder(85));
|
||||
|
||||
$hash = hash('sha256', $encoded);
|
||||
$path = $this->mediaPath($hash);
|
||||
$disk = Storage::disk($this->mediaDiskName());
|
||||
|
||||
$written = $disk->put($path, $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store image in object storage.');
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'width' => (int) $image->width(),
|
||||
'height' => (int) $image->height(),
|
||||
'size_bytes' => strlen($encoded),
|
||||
];
|
||||
}
|
||||
|
||||
private function authorizeNews(Request $request): void
|
||||
{
|
||||
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function mediaDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function mediaPath(string $hash): string
|
||||
{
|
||||
return sprintf(
|
||||
'news/covers/%s/%s/%s.webp',
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
$hash,
|
||||
);
|
||||
}
|
||||
|
||||
private function publicUrlForPath(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function deleteMediaFile(string $path): void
|
||||
{
|
||||
$trimmed = ltrim(trim($path), '/');
|
||||
|
||||
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->mediaDiskName())->delete($trimmed);
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->mediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Studio/StudioUploadQueueApiController.php
Normal file
113
app/Http/Controllers/Studio/StudioUploadQueueApiController.php
Normal file
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,11 @@ use App\Services\UserSuggestionService;
|
||||
use App\Services\Countries\CountryCatalogService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\Worlds\WorldRewardService;
|
||||
use App\Services\XPService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use App\Services\Profile\WorldProfileHistoryService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\CoverUrl;
|
||||
@@ -66,6 +68,7 @@ class ProfileController extends Controller
|
||||
'artworks',
|
||||
'stories',
|
||||
'achievements',
|
||||
'worlds',
|
||||
'collections',
|
||||
'about',
|
||||
'stats',
|
||||
@@ -87,6 +90,8 @@ class ProfileController extends Controller
|
||||
private readonly CountryCatalogService $countryCatalog,
|
||||
private readonly UserSuggestionService $userSuggestions,
|
||||
private readonly CreatorJourneyService $creatorJourney,
|
||||
private readonly WorldRewardService $worldRewards,
|
||||
private readonly WorldProfileHistoryService $worldProfileHistory,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -1267,6 +1272,10 @@ class ProfileController extends Controller
|
||||
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
|
||||
->all();
|
||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
|
||||
$worldHistory = $isOwner
|
||||
? $this->worldProfileHistory->ownerPayloadForUser($user)
|
||||
: $this->worldProfileHistory->publicPayloadForUser($user);
|
||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||
$groupContributionHistory = $this->buildGroupContributionHistory($user);
|
||||
$journey = $this->creatorJourney->publicPayloadForUser($user);
|
||||
@@ -1342,6 +1351,8 @@ class ProfileController extends Controller
|
||||
'creatorStories' => $creatorStories->values(),
|
||||
'collections' => $profileCollectionsPayload,
|
||||
'achievements' => $achievementSummary,
|
||||
'worldRewards' => $worldRewardSummary,
|
||||
'worldHistory' => $worldHistory,
|
||||
'leaderboardRank' => $leaderboardRank,
|
||||
'journey' => $journey,
|
||||
'groupContributionHistory' => $groupContributionHistory,
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TodayDownloadsController extends Controller
|
||||
{
|
||||
@@ -15,20 +15,40 @@ class TodayDownloadsController extends Controller
|
||||
$hits = 30;
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
$artworkVisibilityScope = function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
};
|
||||
$hasTodayDownloads = ArtworkDownload::query()
|
||||
->whereHas('artwork', $artworkVisibilityScope)
|
||||
->whereDate('created_at', $today)
|
||||
->exists();
|
||||
|
||||
$query = ArtworkDownload::with([
|
||||
'artwork.user:id,name,username',
|
||||
'artwork.user.profile:user_id,avatar_hash',
|
||||
'artwork.categories:id,name,slug',
|
||||
])
|
||||
->whereDate('created_at', $today)
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->whereHas('artwork', $artworkVisibilityScope)
|
||||
->selectRaw('artwork_id, COUNT(*) as num_downloads')
|
||||
->groupBy('artwork_id')
|
||||
->orderByDesc('num_downloads');
|
||||
|
||||
if ($hasTodayDownloads) {
|
||||
$query->whereDate('created_at', $today);
|
||||
} else {
|
||||
$fallbackDownloadIds = ArtworkDownload::query()
|
||||
->whereHas('artwork', $artworkVisibilityScope)
|
||||
->orderByDesc('created_at')
|
||||
->limit(1000)
|
||||
->pluck('id');
|
||||
|
||||
if ($fallbackDownloadIds->isEmpty()) {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$query->whereIn('id', $fallbackDownloadIds->all());
|
||||
}
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
@@ -61,7 +81,7 @@ class TodayDownloadsController extends Controller
|
||||
$categoryId = $primaryCategory->id ?? null;
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$avatarHash = $art->user->profile->avatar_hash ?? null;
|
||||
$avatarHash = $art->user?->profile?->avatar_hash;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
@@ -87,8 +107,15 @@ class TodayDownloadsController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Today Downloaded Artworks';
|
||||
$page_title = $hasTodayDownloads
|
||||
? 'Today Downloaded Artworks'
|
||||
: 'Most Downloaded from Latest 1000 Downloads';
|
||||
|
||||
return view('web.downloads.today', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
return view('web.downloads.today', [
|
||||
'page_title' => $page_title,
|
||||
'artworks' => $paginator,
|
||||
'display_date' => $today,
|
||||
'is_fallback_window' => ! $hasTodayDownloads,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
@@ -21,6 +23,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
|
||||
final class ArtworkPageController extends Controller
|
||||
{
|
||||
@@ -29,7 +33,7 @@ final class ArtworkPageController extends Controller
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
|
||||
{
|
||||
// ── Step 1: check existence including soft-deleted ─────────────────
|
||||
$raw = Artwork::withTrashed()->where('id', $id)->first();
|
||||
@@ -181,8 +185,8 @@ final class ArtworkPageController extends Controller
|
||||
$itemSlug = (string) $item->id;
|
||||
}
|
||||
|
||||
$sm = ThumbnailPresenter::present($item, 'sm');
|
||||
$md = ThumbnailPresenter::present($item, 'md');
|
||||
$lg = ThumbnailPresenter::present($item, 'lg');
|
||||
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => (int) $item->id,
|
||||
@@ -192,8 +196,8 @@ final class ArtworkPageController extends Controller
|
||||
'publisher_type' => $item->group ? 'group' : 'user',
|
||||
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||
'thumb' => $sm['url'] ?? null,
|
||||
'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w',
|
||||
], $item, request()->user());
|
||||
})
|
||||
->values()
|
||||
@@ -249,20 +253,65 @@ final class ArtworkPageController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $artwork,
|
||||
'artworkData' => $artworkData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'meta' => $meta,
|
||||
'seo' => $seo,
|
||||
'useUnifiedSeo' => true,
|
||||
'relatedItems' => $related,
|
||||
'comments' => $comments,
|
||||
'groupSummary' => $groupSummary,
|
||||
]);
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
|
||||
$userId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
|
||||
|
||||
return Inertia::render('ArtworkPage', [
|
||||
'artwork' => $artworkData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'comments' => $comments,
|
||||
'groupSummary' => $groupSummary,
|
||||
'isAuthenticated' => $userId !== null,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $userId),
|
||||
'seo' => $seo,
|
||||
])->rootView('artworks.show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-slug reaction totals for the given artwork, including
|
||||
* whether the given user has each reaction (mine=true).
|
||||
*
|
||||
* Mirrors ReactionController::getTotals() so the page can render
|
||||
* the correct state without a separate client-side fetch on first load.
|
||||
*/
|
||||
private function artworkReactionTotals(int $artworkId, ?int $userId): array
|
||||
{
|
||||
$rows = DB::table('artwork_reactions')
|
||||
->where('artwork_id', $artworkId)
|
||||
->selectRaw('reaction, COUNT(*) as total')
|
||||
->groupBy('reaction')
|
||||
->get()
|
||||
->keyBy('reaction');
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$count = (int) ($rows[$slug]->total ?? 0);
|
||||
|
||||
$mine = false;
|
||||
if ($userId !== null && $count > 0) {
|
||||
$mine = DB::table('artwork_reactions')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('reaction', $slug)
|
||||
->where('user_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$totals[$slug] = [
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => $count,
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/** Silently catch suggestion query failures so error page never crashes. */
|
||||
|
||||
@@ -20,6 +20,8 @@ use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
|
||||
class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
{
|
||||
private const CACHE_VERSION = 'v4';
|
||||
|
||||
/**
|
||||
* Meilisearch sort-field arrays per sort alias.
|
||||
* First element is primary sort; subsequent elements are tie-breakers.
|
||||
@@ -28,18 +30,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
// ── Nova sort aliases ─────────────────────────────────────────────────
|
||||
// trending_score_24h only covers artworks ≤ 7 days old; use 7d score
|
||||
// and favorites_count as fallbacks so older artworks don't all tie at 0.
|
||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'published_at_ts:desc'],
|
||||
// "New & Hot": 30-day trending window surfaces recently-active artworks.
|
||||
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'fresh' => ['published_at_ts:desc', 'trending_score_7d:desc', 'favorites_count:desc'],
|
||||
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
|
||||
'oldest' => ['created_at:asc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'oldest' => ['published_at_ts:asc'],
|
||||
// ── Legacy aliases (backward compat) ──────────────────────────────────
|
||||
'latest' => ['created_at:desc'],
|
||||
'popular' => ['views:desc', 'favorites_count:desc'],
|
||||
'liked' => ['likes:desc', 'favorites_count:desc'],
|
||||
'downloads' => ['downloads:desc', 'downloads_count:desc'],
|
||||
'latest' => ['published_at_ts:desc'],
|
||||
'popular' => ['views:desc', 'favorites_count:desc', 'published_at_ts:desc'],
|
||||
'liked' => ['likes:desc', 'favorites_count:desc', 'published_at_ts:desc'],
|
||||
'downloads' => ['downloads:desc', 'downloads_count:desc', 'published_at_ts:desc'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -66,6 +68,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
private const SORT_OPTIONS = [
|
||||
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||
['value' => 'fresh', 'label' => '🆕 Fresh'],
|
||||
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
|
||||
['value' => 'favorited', 'label' => '❤️ Most Favorited'],
|
||||
['value' => 'downloaded', 'label' => '⬇ Most Downloaded'],
|
||||
@@ -88,11 +91,11 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
|
||||
|
||||
$artworks = Cache::remember(
|
||||
"browse.all.catalog-visible.v2.{$sort}.{$page}",
|
||||
"browse.all.catalog-visible." . self::CACHE_VERSION . ".{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
@@ -150,11 +153,11 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$normalizedPath = trim((string) $path, '/');
|
||||
if ($normalizedPath === '') {
|
||||
$artworks = Cache::remember(
|
||||
"gallery.ct.catalog-visible.v2.{$contentSlug}.{$sort}.{$page}",
|
||||
"gallery.ct.catalog-visible." . self::CACHE_VERSION . ".{$contentSlug}.{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
'filter' => 'is_public = true AND is_approved = true AND ' . $this->contentTypeFilterClause($contentSlug),
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
@@ -192,16 +195,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
}
|
||||
|
||||
$categorySlugs = $this->categoryFilterSlugs($category);
|
||||
$categoryFilter = collect($categorySlugs)
|
||||
->map(fn (string $slug) => 'category = "' . addslashes($slug) . '"')
|
||||
->implode(' OR ');
|
||||
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
|
||||
|
||||
$artworks = Cache::remember(
|
||||
'gallery.cat.catalog-visible.v2.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
'gallery.cat.catalog-visible.' . self::CACHE_VERSION . '.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
|
||||
$ttl,
|
||||
fn () => $this->search->searchWithThumbnailPreference([
|
||||
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
'filter' => $filterExpression,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
|
||||
], $perPage, false, $page)
|
||||
);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
@@ -369,6 +370,31 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
private function categoryFilterClause(string $categorySlug): string
|
||||
{
|
||||
$quoted = addslashes($categorySlug);
|
||||
|
||||
return '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
private function categoryPageFilterExpression(string $contentTypeSlug, array $categorySlugs): string
|
||||
{
|
||||
$categoryFilter = collect($categorySlugs)
|
||||
->map(fn (string $slug) => $this->categoryFilterClause($slug))
|
||||
->implode(' OR ');
|
||||
|
||||
return 'is_public = true AND is_approved = true AND '
|
||||
. $this->contentTypeFilterClause($contentTypeSlug)
|
||||
. ' AND (' . $categoryFilter . ')';
|
||||
}
|
||||
|
||||
private function contentTypeFilterClause(string $contentTypeSlug): string
|
||||
{
|
||||
$quoted = addslashes($contentTypeSlug);
|
||||
|
||||
return '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$limit = (int) $request->query('limit', 0);
|
||||
@@ -393,7 +419,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
private function mainCategories(): Collection
|
||||
{
|
||||
return $this->contentTypeResolver
|
||||
->publicContentTypes()
|
||||
->toolbarContentTypes()
|
||||
->map(function (ContentType $type) {
|
||||
return (object) [
|
||||
'id' => $type->id,
|
||||
|
||||
@@ -5,21 +5,24 @@ namespace App\Http\Controllers\Web;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\CategoryDirectoryService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworkService;
|
||||
protected CategoryDirectoryService $categoryDirectory;
|
||||
|
||||
public function __construct(ArtworkService $artworkService)
|
||||
public function __construct(ArtworkService $artworkService, CategoryDirectoryService $categoryDirectory)
|
||||
{
|
||||
$this->artworkService = $artworkService;
|
||||
$this->categoryDirectory = $categoryDirectory;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return $this->browseCategories();
|
||||
return $this->browseCategories($request);
|
||||
}
|
||||
|
||||
public function show(Request $request, $id, $slug = null, $group = null)
|
||||
@@ -58,20 +61,7 @@ class CategoryController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
|
||||
$q->where('slug', strtolower($contentTypeSlug));
|
||||
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
|
||||
|
||||
if ($category && count($parts) > 1) {
|
||||
$cur = $category;
|
||||
foreach (array_slice($parts, 1) as $slugPart) {
|
||||
$cur = $cur->children()->where('slug', strtolower($slugPart))->first();
|
||||
if (! $cur) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
$category = $cur;
|
||||
}
|
||||
$category = $this->artworkService->resolveCategoryByPath($slugs);
|
||||
} catch (\Throwable $e) {
|
||||
$category = null;
|
||||
}
|
||||
@@ -109,12 +99,19 @@ class CategoryController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function browseCategories()
|
||||
public function browseCategories(Request $request)
|
||||
{
|
||||
$pageTitle = 'All Categories – Wallpapers, Skins & Digital Art | Skinbase';
|
||||
$pageDescription = 'Browse all categories on Skinbase including wallpapers, skins, themes, and digital art collections.';
|
||||
$payload = $this->categoryDirectory->getDirectoryPayload(
|
||||
(string) $request->query('q', ''),
|
||||
(string) $request->query('sort', 'popular'),
|
||||
(int) $request->query('page', 1),
|
||||
(int) $request->query('per_page', 24),
|
||||
);
|
||||
|
||||
return view('web.categories', [
|
||||
'initialPayload' => $payload,
|
||||
'page_title' => $pageTitle,
|
||||
'page_meta_description' => $pageDescription,
|
||||
'page_canonical' => url('/categories'),
|
||||
|
||||
@@ -176,6 +176,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])
|
||||
@@ -551,6 +552,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');
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
@@ -238,12 +239,102 @@ final class ExploreController extends Controller
|
||||
return $this->byType($request, $type);
|
||||
}
|
||||
|
||||
// ── /explore/best (Hall of Fame) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hall of Fame: all-time highest-medal artworks, ranked by prestige.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Primary: score_total DESC (all-time weighted medal score: gold×5 + silver×3 + bronze×1)
|
||||
* 2. Secondary: gold_count DESC (prestige tiebreak — golds are rarer and more deliberate)
|
||||
* 3. Tertiary: favorites_count DESC (overall community love)
|
||||
*
|
||||
* Only artworks published ≥ 30 days ago are eligible so freshly-viral
|
||||
* pieces don't crowd out genuine all-time standouts.
|
||||
*
|
||||
* Cache TTL is 1 hour — rankings shift slowly for the HoF.
|
||||
*/
|
||||
public function hallOfFame(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$minAge = now()->subDays(30);
|
||||
$maturityUser = $request->user();
|
||||
|
||||
$cacheVersion = $this->cacheVersion();
|
||||
$viewerSegment = $maturityUser ? 'auth.' . $maturityUser->id : 'guest';
|
||||
$cacheKey = "explore.hall-of-fame.v{$cacheVersion}.{$viewerSegment}.p{$page}";
|
||||
|
||||
$paginator = Cache::remember($cacheKey, 3600, function () use ($perPage, $page, $minAge, $maturityUser): LengthAwarePaginator {
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn ($b) => $this->maturity->applyViewerFilter($b, $maturityUser))
|
||||
->withoutMissingThumbnails()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,headline,avatar_path,followers_count',
|
||||
'categories:id,name,slug,content_type_id,sort_order',
|
||||
'categories.contentType:id,name,slug',
|
||||
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total',
|
||||
'stats:artwork_id,favorites',
|
||||
])
|
||||
->leftJoin('artwork_medal_stats as hof', 'hof.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_stats as hof_stats', 'hof_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
// Must have at least one medal
|
||||
->whereRaw('COALESCE(hof.score_total, 0) > 0')
|
||||
// Minimum 30-day age to exclude freshly-viral pieces
|
||||
->where('artworks.published_at', '<=', $minAge)
|
||||
// Ranking: prestige-weighted medal score, then gold count, then favorites
|
||||
->orderByRaw('COALESCE(hof.score_total, 0) DESC')
|
||||
->orderByRaw('COALESCE(hof.gold_count, 0) DESC')
|
||||
->orderByRaw('COALESCE(hof_stats.favorites, 0) DESC');
|
||||
|
||||
return $query->paginate($perPage, ['artworks.*'], 'page', $page)
|
||||
->withPath(url('/explore/best'));
|
||||
});
|
||||
|
||||
$paginator->getCollection()->transform(fn (Artwork $a) => $this->presentArtwork($a));
|
||||
|
||||
$mainCategories = $this->mainCategories();
|
||||
$seo = $this->paginationSeo($request, url('/explore/best'), $paginator);
|
||||
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'browse',
|
||||
'gallery_nav_section' => 'artworks',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => $mainCategories,
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
'artworks' => $paginator,
|
||||
'spotlight' => collect(),
|
||||
'hide_rank_tabs' => true,
|
||||
'current_sort' => 'top-rated',
|
||||
'sort_options' => [],
|
||||
'hero_title' => 'Hall of Fame',
|
||||
'hero_description' => 'All-time medal standouts ranked by prestige — the artworks the community has honoured most across the years.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => 'Hall of Fame', 'url' => '/explore/best'],
|
||||
]),
|
||||
'page_title' => 'Hall of Fame — All-Time Best Artworks - Skinbase',
|
||||
'page_meta_description' => 'The highest-medal artworks of all time on Skinbase, ranked by gold, silver and bronze prestige.',
|
||||
'page_meta_keywords' => 'hall of fame, best artworks, top rated, medals, skinbase',
|
||||
'page_canonical' => $seo['canonical'],
|
||||
'page_rel_prev' => $seo['prev'],
|
||||
'page_rel_next' => $seo['next'],
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private function mainCategories(): Collection
|
||||
{
|
||||
$categories = $this->contentTypeResolver
|
||||
->publicContentTypes()
|
||||
->toolbarContentTypes()
|
||||
->map(fn ($ct) => (object) [
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
@@ -311,7 +402,8 @@ final class ExploreController extends Controller
|
||||
];
|
||||
|
||||
if ($contentType !== null && $contentType !== '') {
|
||||
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
|
||||
$quoted = addslashes($contentType);
|
||||
$filterParts[] = '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
|
||||
}
|
||||
|
||||
$orientation = strtolower(trim((string) $request->query('orientation', '')));
|
||||
|
||||
@@ -22,6 +22,7 @@ class FeaturedArtworksController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 39;
|
||||
$viewer = $request->user();
|
||||
|
||||
$type = (int) ($request->query('type', 4));
|
||||
|
||||
@@ -31,32 +32,32 @@ class FeaturedArtworksController extends Controller
|
||||
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
|
||||
|
||||
$artworks->setCollection(
|
||||
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork): array {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
|
||||
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork) use ($viewer): array {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
|
||||
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'gid_num' => $gid,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $username,
|
||||
], $artwork, $request->user());
|
||||
})->values()->all(), $request->user()))
|
||||
return $this->maturity->decoratePayload([
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'gid_num' => $gid,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $username,
|
||||
], $artwork, $viewer);
|
||||
})->values()->all(), $viewer))
|
||||
->map(static fn (array $item): object => (object) $item)
|
||||
->values()
|
||||
);
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class FollowingFeedController extends Controller
|
||||
{
|
||||
public function __construct(private SeoFactory $seoFactory) {}
|
||||
|
||||
/**
|
||||
* GET /feed/following
|
||||
* Renders the Following Feed Inertia page.
|
||||
@@ -16,6 +19,13 @@ class FollowingFeedController extends Controller
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Following Feed — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Posts from creators you follow on Skinbase.',
|
||||
canonical: url('/feed/following'),
|
||||
indexable: false,
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/FollowingFeed', [
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
@@ -25,6 +35,7 @@ class FollowingFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
] : null,
|
||||
],
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,26 @@
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class HashtagFeedController extends Controller
|
||||
{
|
||||
public function __construct(private SeoFactory $seoFactory) {}
|
||||
|
||||
/** GET /tags/{tag} */
|
||||
public function index(Request $request, string $tag): Response
|
||||
{
|
||||
$normalTag = strtolower($tag);
|
||||
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: '#' . $normalTag . ' — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Explore posts tagged with #' . $normalTag . ' on Skinbase.',
|
||||
canonical: url('/tags/' . rawurlencode($normalTag)),
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/HashtagFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
@@ -21,7 +32,8 @@ class HashtagFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'tag' => strtolower($tag),
|
||||
'tag' => $normalTag,
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SavedFeedController extends Controller
|
||||
{
|
||||
public function __construct(private SeoFactory $seoFactory) {}
|
||||
|
||||
/** GET /feed/saved */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Saved Posts — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Your saved posts on Skinbase.',
|
||||
canonical: url('/feed/saved'),
|
||||
indexable: false,
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/SavedFeed', [
|
||||
'auth' => [
|
||||
'user' => [
|
||||
@@ -21,6 +31,7 @@ class SavedFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
],
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
@@ -11,7 +12,10 @@ use Inertia\Response;
|
||||
|
||||
class SearchFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
public function __construct(
|
||||
private PostHashtagService $hashtagService,
|
||||
private SeoFactory $seoFactory,
|
||||
) {}
|
||||
|
||||
/** GET /feed/search */
|
||||
public function index(Request $request): Response
|
||||
@@ -22,6 +26,12 @@ class SearchFeedController extends Controller
|
||||
fn () => $this->hashtagService->trending(10, 24)
|
||||
);
|
||||
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Search Posts — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Search posts, hashtags and creators on Skinbase.',
|
||||
canonical: url('/feed/search'),
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/SearchFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
@@ -31,8 +41,9 @@ class SearchFeedController extends Controller
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'initialQuery' => $request->query('q', ''),
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
'initialQuery' => $request->query('q', ''),
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
@@ -11,13 +12,22 @@ use Inertia\Response;
|
||||
|
||||
class TrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
public function __construct(
|
||||
private PostHashtagService $hashtagService,
|
||||
private SeoFactory $seoFactory,
|
||||
) {}
|
||||
|
||||
/** GET /feed/trending */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$trendingHashtags = Cache::remember('trending_hashtags', 300, fn () => $this->hashtagService->trending(10, 24));
|
||||
|
||||
$seo = $this->seoFactory->simplePage(
|
||||
title: 'Trending Posts — ' . config('seo.site_name', 'Skinbase'),
|
||||
description: 'Discover the most popular and engaging posts on Skinbase right now.',
|
||||
canonical: url('/feed/trending'),
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/TrendingFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
@@ -28,6 +38,7 @@ class TrendingFeedController extends Controller
|
||||
],
|
||||
] : null,
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
'seo' => $seo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,32 @@ use App\Http\Resources\ArtworkListResource;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\GroupDiscoveryService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\View\View;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class SearchController extends Controller
|
||||
{
|
||||
private const ALLOWED_SORTS = ['latest', 'popular', 'likes', 'downloads'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GroupDiscoveryService $groups,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
$sort = $request->query('sort', 'latest');
|
||||
$canonicalQuery = $this->canonicalQueryParameters($request);
|
||||
$canonicalUrl = $this->canonicalSearchUrl($request, $canonicalQuery);
|
||||
|
||||
if ($request->fullUrl() !== $canonicalUrl) {
|
||||
return redirect()->to($canonicalUrl, 301);
|
||||
}
|
||||
|
||||
$q = (string) ($canonicalQuery['q'] ?? '');
|
||||
$sort = (string) ($canonicalQuery['sort'] ?? 'latest');
|
||||
$hasQuery = $q !== '';
|
||||
|
||||
$sortMap = [
|
||||
@@ -98,4 +109,81 @@ final class SearchController extends Controller
|
||||
'page_robots' => 'noindex,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
private function canonicalQueryParameters(Request $request): array
|
||||
{
|
||||
$q = $this->normalizeSearchQuery($request->query('q', ''));
|
||||
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = ['q' => $q];
|
||||
$sort = $this->normalizeSort($request->query('sort', 'latest'));
|
||||
$page = $this->normalizePage($request->query('page', 1));
|
||||
|
||||
if ($sort !== 'latest') {
|
||||
$params['sort'] = $sort;
|
||||
}
|
||||
|
||||
if ($page > 1) {
|
||||
$params['page'] = $page;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string> $params
|
||||
*/
|
||||
private function canonicalSearchUrl(Request $request, array $params): string
|
||||
{
|
||||
$query = Arr::query($params);
|
||||
|
||||
return $query === '' ? $request->url() : $request->url() . '?' . $query;
|
||||
}
|
||||
|
||||
private function normalizeSearchQuery(mixed $value): string
|
||||
{
|
||||
$query = html_entity_decode($this->firstScalarValue($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$query = preg_replace('/(?:\?|&)(?:amp;)?(?:page|sort|filter|group|id|txtfilter|q)=.*$/i', '', $query) ?? $query;
|
||||
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
|
||||
|
||||
return trim($query, " \t\n\r\0\x0B?&");
|
||||
}
|
||||
|
||||
private function normalizeSort(mixed $value): string
|
||||
{
|
||||
$sort = strtolower($this->firstScalarValue($value));
|
||||
$sort = preg_replace('/(?:\?|&).*/', '', $sort) ?? $sort;
|
||||
|
||||
return in_array($sort, self::ALLOWED_SORTS, true) ? $sort : 'latest';
|
||||
}
|
||||
|
||||
private function normalizePage(mixed $value): int
|
||||
{
|
||||
$page = $this->firstScalarValue($value);
|
||||
|
||||
if (preg_match('/\d+/', $page, $matches) !== 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return max(1, (int) $matches[0]);
|
||||
}
|
||||
|
||||
private function firstScalarValue(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = reset($value);
|
||||
}
|
||||
|
||||
if (! is_scalar($value) && $value !== null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ final class SimilarArtworksPageController extends Controller
|
||||
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
|
||||
$quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ final class TagController extends Controller
|
||||
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
||||
|
||||
// Sidebar: main content type links (same as browse gallery)
|
||||
$mainCategories = ContentType::ordered()->get(['name', 'slug'])
|
||||
$mainCategories = ContentType::ordered()->where('hide_from_menu', false)->get(['name', 'slug'])
|
||||
->map(fn ($type) => (object) [
|
||||
'id' => $type->id,
|
||||
'name' => $type->name,
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\World;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -32,16 +32,47 @@ final class WorldController extends Controller
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
public function show(Request $request, World $world): Response
|
||||
public function show(Request $request, string $world): Response|RedirectResponse
|
||||
{
|
||||
abort_unless($world->isPubliclyVisible(), 404);
|
||||
$resolution = $this->worlds->resolvePublicWorld($world);
|
||||
$resolvedWorld = $resolution['world'] ?? null;
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($world, $request->user());
|
||||
abort_unless($resolvedWorld !== null, 404);
|
||||
|
||||
if (! empty($resolution['redirect'])) {
|
||||
return redirect()->to((string) $resolution['redirect'], 301);
|
||||
}
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$world->seo_title ?: ($world->title . ' — Skinbase Nova'),
|
||||
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
route('worlds.show', ['world' => $world->slug]),
|
||||
$world->ogImageUrl(),
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
$this->worlds->canonicalPublicUrl($resolvedWorld),
|
||||
$resolvedWorld->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
'seo' => $seo,
|
||||
]))->rootView('collections');
|
||||
}
|
||||
|
||||
public function showEdition(Request $request, string $world, int $year): Response|RedirectResponse
|
||||
{
|
||||
$resolution = $this->worlds->resolvePublicEdition($world, $year);
|
||||
$resolvedWorld = $resolution['world'] ?? null;
|
||||
|
||||
abort_unless($resolvedWorld !== null, 404);
|
||||
|
||||
if (! empty($resolution['redirect'])) {
|
||||
return redirect()->to((string) $resolution['redirect'], 301);
|
||||
}
|
||||
|
||||
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
|
||||
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
|
||||
$this->worlds->canonicalPublicUrl($resolvedWorld),
|
||||
$resolvedWorld->ogImageUrl(),
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('World/WorldShow', array_merge($payload, [
|
||||
|
||||
Reference in New Issue
Block a user