- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
146 lines
4.8 KiB
PHP
146 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Resources\ArtworkListResource;
|
|
use App\Models\Artwork;
|
|
use App\Models\Category;
|
|
use App\Models\ContentType;
|
|
use App\Services\RankingService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
|
|
/**
|
|
* RankController
|
|
*
|
|
* Serves pre-computed ranked artwork lists.
|
|
*
|
|
* Endpoints:
|
|
* GET /api/rank/global?type=trending|new_hot|best
|
|
* GET /api/rank/category/{id}?type=trending|new_hot|best
|
|
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
|
|
*/
|
|
class RankController extends Controller
|
|
{
|
|
public function __construct(private readonly RankingService $ranking) {}
|
|
|
|
/**
|
|
* GET /api/rank/global
|
|
*
|
|
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
|
|
*/
|
|
public function global(Request $request): AnonymousResourceCollection|JsonResponse
|
|
{
|
|
$listType = $this->resolveListType($request);
|
|
$result = $this->ranking->getList('global', null, $listType);
|
|
|
|
return $this->buildResponse($result, $listType);
|
|
}
|
|
|
|
/**
|
|
* GET /api/rank/category/{id}
|
|
*/
|
|
public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse
|
|
{
|
|
if (! Category::where('id', $id)->where('is_active', true)->exists()) {
|
|
return response()->json(['message' => 'Category not found.'], 404);
|
|
}
|
|
|
|
$listType = $this->resolveListType($request);
|
|
$result = $this->ranking->getList('category', $id, $listType);
|
|
|
|
return $this->buildResponse($result, $listType);
|
|
}
|
|
|
|
/**
|
|
* GET /api/rank/type/{contentType}
|
|
*
|
|
* {contentType} is accepted as either a slug (string) or numeric id.
|
|
*/
|
|
public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse
|
|
{
|
|
$ct = is_numeric($contentType)
|
|
? ContentType::find((int) $contentType)
|
|
: ContentType::where('slug', $contentType)->first();
|
|
|
|
if ($ct === null) {
|
|
return response()->json(['message' => 'Content type not found.'], 404);
|
|
}
|
|
|
|
$listType = $this->resolveListType($request);
|
|
$result = $this->ranking->getList('content_type', $ct->id, $listType);
|
|
|
|
return $this->buildResponse($result, $listType);
|
|
}
|
|
|
|
// ── Private helpers ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validate and normalise the ?type query param.
|
|
* Defaults to 'trending'.
|
|
*/
|
|
private function resolveListType(Request $request): string
|
|
{
|
|
$allowed = ['trending', 'new_hot', 'best'];
|
|
$type = $request->query('type', 'trending');
|
|
|
|
return in_array($type, $allowed, true) ? $type : 'trending';
|
|
}
|
|
|
|
/**
|
|
* Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources.
|
|
*
|
|
* @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result
|
|
*/
|
|
private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection
|
|
{
|
|
$ids = $result['ids'];
|
|
$artworks = collect();
|
|
|
|
if (! empty($ids)) {
|
|
// Single whereIn query — no N+1
|
|
$keyed = Artwork::whereIn('id', $ids)
|
|
->with([
|
|
'user:id,name',
|
|
'user.profile:user_id,avatar_hash',
|
|
'categories' => function ($q): void {
|
|
$q->select(
|
|
'categories.id',
|
|
'categories.content_type_id',
|
|
'categories.parent_id',
|
|
'categories.name',
|
|
'categories.slug',
|
|
'categories.sort_order'
|
|
)->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
|
},
|
|
])
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
// Restore the ranked order
|
|
$artworks = collect($ids)
|
|
->filter(fn ($id) => $keyed->has($id))
|
|
->map(fn ($id) => $keyed[$id]);
|
|
}
|
|
|
|
$collection = ArtworkListResource::collection($artworks);
|
|
|
|
// Attach ranking meta as additional data
|
|
$collection->additional([
|
|
'meta' => [
|
|
'list_type' => $listType,
|
|
'computed_at' => $result['computed_at'],
|
|
'model_version' => $result['model_version'],
|
|
'fallback' => $result['fallback'],
|
|
'count' => $artworks->count(),
|
|
],
|
|
]);
|
|
|
|
return $collection;
|
|
}
|
|
}
|