Lazy-load profile collections until the tab is opened.
Keep the profile payload smaller on first paint and fetch collections through a dedicated API when the collections tab is needed.
This commit is contained in:
@@ -7,10 +7,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\UsernamePolicy;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,6 +27,28 @@ use UnexpectedValueException;
|
||||
*/
|
||||
final class ProfileApiController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CollectionService $collections) {}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/collections
|
||||
* Returns the profile collection cards without rendering the profile page.
|
||||
*/
|
||||
public function collections(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$viewer = $request->user();
|
||||
$isOwner = $viewer && (int) $viewer->id === (int) $user->id;
|
||||
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/artworks
|
||||
* Returns cursor-paginated artworks for the profile page tabs.
|
||||
@@ -39,18 +62,18 @@ final class ProfileApiController extends Controller
|
||||
}
|
||||
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('artworks.deleted_at');
|
||||
|
||||
@@ -85,9 +108,9 @@ final class ProfileApiController extends Controller
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -110,7 +133,7 @@ final class ProfileApiController extends Controller
|
||||
$perPage = 12;
|
||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
$favIds = DB::table($favouriteTable.' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
@@ -128,22 +151,22 @@ final class ProfileApiController extends Controller
|
||||
->pluck('a.id');
|
||||
|
||||
$hasMore = $favIds->count() > $perPage;
|
||||
$favIds = $favIds->take($perPage);
|
||||
$favIds = $favIds->take($perPage);
|
||||
|
||||
if ($favIds->isEmpty()) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
@@ -154,9 +177,9 @@ final class ProfileApiController extends Controller
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
||||
'has_more' => $hasMore,
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -182,7 +205,7 @@ final class ProfileApiController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => $stats,
|
||||
'stats' => $stats,
|
||||
'follower_count' => $followerCount,
|
||||
]);
|
||||
}
|
||||
@@ -190,6 +213,7 @@ final class ProfileApiController extends Controller
|
||||
private function resolveUser(string $username): ?User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
@@ -208,17 +232,17 @@ final class ProfileApiController extends Controller
|
||||
{
|
||||
$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,
|
||||
'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.*')
|
||||
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
|
||||
->selectRaw('COALESCE('.$statsColumn.', 0) as cursor_sort_value')
|
||||
->orderByDesc('cursor_sort_value')
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id');
|
||||
@@ -235,7 +259,7 @@ final class ProfileApiController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$category = $art->resolvedPrimaryCategory();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
$group = $art->group;
|
||||
@@ -245,7 +269,7 @@ final class ProfileApiController extends Controller
|
||||
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||
$profileUrl = $isGroupPublisher
|
||||
? $group->publicUrl()
|
||||
: ($username ? '/@' . $username : null);
|
||||
: ($username ? '/@'.$username : null);
|
||||
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user