diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php index de27b38e..fdcf958d 100644 --- a/app/Http/Controllers/Api/ProfileApiController.php +++ b/app/Http/Controllers/Api/ProfileApiController.php @@ -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 [ diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 1b3c2562..6af6bd97 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -25,40 +25,43 @@ use App\Models\GroupReleaseContributor; use App\Models\ProfileComment; use App\Models\Story; use App\Models\User; -use Carbon\CarbonInterface; -use App\Services\Security\CaptchaVerifier; -use App\Services\AvatarService; -use App\Services\ArtworkService; -use App\Services\FollowService; use App\Services\AchievementService; +use App\Services\ArtworkService; +use App\Services\AvatarService; use App\Services\CollectionService; -use App\Services\FollowAnalyticsService; -use App\Services\LeaderboardService; -use App\Services\UserSuggestionService; use App\Services\Countries\CountryCatalogService; +use App\Services\FollowAnalyticsService; +use App\Services\FollowService; +use App\Services\LeaderboardService; 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\Security\CaptchaVerifier; +use App\Services\ThumbnailPresenter; +use App\Services\UsernameApprovalService; use App\Services\UserStatsService; +use App\Services\UserSuggestionService; +use App\Services\Worlds\WorldRewardService; +use App\Services\XPService; use App\Support\AvatarUrl; use App\Support\CoverUrl; use App\Support\Seo\SeoFactory; use App\Support\UsernamePolicy; +use Carbon\Carbon; +use Carbon\CarbonInterface; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Redirect; -use Illuminate\Support\Facades\Schema; -use Illuminate\View\View; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rules\Password as PasswordRule; +use Illuminate\View\View; use Inertia\Inertia; class ProfileController extends Controller @@ -95,9 +98,7 @@ class ProfileController extends Controller private readonly CreatorJourneyService $creatorJourney, private readonly WorldRewardService $worldRewards, private readonly WorldProfileHistoryService $worldProfileHistory, - ) - { - } + ) {} public function showByUsername(Request $request, string $username) { @@ -253,11 +254,11 @@ class ProfileController extends Controller abort(404); } - $url = url('/@' . strtolower((string) $user->username) . '/followers'); + $url = url('/@'.strtolower((string) $user->username).'/followers'); $query = $request->query(); if ($query !== []) { - $url .= '?' . http_build_query($query); + $url .= '?'.http_build_query($query); } return redirect()->to($url, 301); @@ -267,18 +268,18 @@ class ProfileController extends Controller public function toggleFollow(Request $request, string $username): JsonResponse { $normalized = UsernamePolicy::normalize($username); - $target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); - $actorId = (int) Auth::id(); + $target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); + $actorId = (int) Auth::id(); if ($actorId === $target->id) { return response()->json(['error' => 'Cannot follow yourself.'], 422); } $following = $this->followService->toggle($actorId, (int) $target->id); - $count = $this->followService->followersCount((int) $target->id); + $count = $this->followService->followersCount((int) $target->id); return response()->json([ - 'following' => $following, + 'following' => $following, 'follower_count' => $count, ]); } @@ -295,8 +296,8 @@ class ProfileController extends Controller $comment = ProfileComment::create([ 'profile_user_id' => $target->id, - 'author_user_id' => Auth::id(), - 'body' => $request->input('body'), + 'author_user_id' => Auth::id(), + 'body' => $request->input('body'), ]); app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile'); @@ -347,27 +348,49 @@ class ProfileController extends Controller $profile = DB::table('user_profiles')->where('user_id', $user->id)->first(); if ($profile) { $profileData = (array) $profile; - if (isset($profile->website)) $user->homepage = $profile->website; - if (isset($profile->about)) $user->about_me = $profile->about; - if (isset($profile->birthdate)) $user->birth = $profile->birthdate; - if (isset($profile->gender)) $user->gender = $profile->gender; - if (isset($profile->country_code)) $user->country_code = $profile->country_code; - if (isset($profile->signature)) $user->signature = $profile->signature; - if (isset($profile->description)) $user->description = $profile->description; - if (isset($profile->mlist)) $user->mlist = $profile->mlist; - if (isset($profile->friend_upload_notice)) $user->friend_upload_notice = $profile->friend_upload_notice; - if (isset($profile->auto_post_upload)) $user->auto_post_upload = $profile->auto_post_upload; + if (isset($profile->website)) { + $user->homepage = $profile->website; + } + if (isset($profile->about)) { + $user->about_me = $profile->about; + } + if (isset($profile->birthdate)) { + $user->birth = $profile->birthdate; + } + if (isset($profile->gender)) { + $user->gender = $profile->gender; + } + if (isset($profile->country_code)) { + $user->country_code = $profile->country_code; + } + if (isset($profile->signature)) { + $user->signature = $profile->signature; + } + if (isset($profile->description)) { + $user->description = $profile->description; + } + if (isset($profile->mlist)) { + $user->mlist = $profile->mlist; + } + if (isset($profile->friend_upload_notice)) { + $user->friend_upload_notice = $profile->friend_upload_notice; + } + if (isset($profile->auto_post_upload)) { + $user->auto_post_upload = $profile->auto_post_upload; + } } } - } catch (\Throwable $e) {} + } catch (\Throwable $e) { + } - if (!empty($user->birth)) { + if (! empty($user->birth)) { try { - $dt = \Carbon\Carbon::parse($user->birth); + $dt = Carbon::parse($user->birth); $birthDay = $dt->format('d'); $birthMonth = $dt->format('m'); $birthYear = $dt->format('Y'); - } catch (\Throwable $e) {} + } catch (\Throwable $e) { + } } $selectedCountry = $this->countryCatalog->resolveUserCountry($user); @@ -375,7 +398,7 @@ class ProfileController extends Controller // Avatar URL $avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null; - $avatarUrl = !empty($avatarHash) + $avatarUrl = ! empty($avatarHash) ? AvatarUrl::forUser((int) $user->id, $avatarHash, 256) : AvatarUrl::default(); @@ -449,7 +472,7 @@ class ProfileController extends Controller $avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256); - if (!empty($validated['remove_avatar'])) { + if (! empty($validated['remove_avatar'])) { $avatarService->removeAvatar((int) $user->id); $avatarUrl = AvatarUrl::default(); } @@ -631,8 +654,8 @@ class ProfileController extends Controller 'country_code' => $selectedCountry?->iso2, ]; - if (!empty($validated['gender'])) { - $profileUpdates['gender'] = strtoupper((string) $validated['gender']); + if (array_key_exists('gender', $validated)) { + $profileUpdates['gender'] = $this->normalizeProfileGender($validated['gender'] ?? null); } $this->persistProfileUpdates((int) $request->user()->id, $profileUpdates); @@ -698,7 +721,7 @@ class ProfileController extends Controller private function persistProfileUpdates(int $userId, array $updates): void { - if ($updates === [] || !Schema::hasTable('user_profiles')) { + if ($updates === [] || ! Schema::hasTable('user_profiles')) { return; } @@ -716,6 +739,29 @@ class ProfileController extends Controller DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered); } + /** + * Normalize gender to the MySQL enum values used by user_profiles.gender. + */ + private function normalizeProfileGender(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = strtolower(trim((string) $value)); + + if ($normalized === '') { + return null; + } + + return match ($normalized) { + 'm', 'male', 'man', 'boy' => 'M', + 'f', 'female', 'woman', 'girl' => 'F', + 'x', 'n', 'na', 'n/a', 'nonbinary', 'non-binary', 'prefer_not_to_say', 'prefer not to say' => 'X', + default => null, + }; + } + private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country { if (is_numeric($countryId) && (int) $countryId > 0) { @@ -799,7 +845,7 @@ class ProfileController extends Controller ); } - public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse + public function update(ProfileUpdateRequest $request, AvatarService $avatarService): RedirectResponse|JsonResponse { $user = $request->user(); @@ -826,6 +872,7 @@ class ProfileController extends Controller if ($request->expectsJson()) { return response()->json(['errors' => $error], 422); } + return Redirect::back()->withErrors($error); } @@ -838,6 +885,7 @@ class ProfileController extends Controller if ($request->expectsJson()) { return response()->json(['errors' => $error], 422); } + return Redirect::back()->withErrors($error); } @@ -852,7 +900,7 @@ class ProfileController extends Controller } } - if (!empty($validated['email']) && empty($user->email)) { + if (! empty($validated['email']) && empty($user->email)) { $user->email = $validated['email']; $user->email_verified_at = null; } @@ -860,11 +908,13 @@ class ProfileController extends Controller $user->save(); $profileUpdates = []; - if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about']; + if (! empty($validated['about'])) { + $profileUpdates['about'] = $validated['about']; + } - if (!empty($validated['web'])) { + if (! empty($validated['web'])) { $profileUpdates['website'] = $validated['web']; - } elseif (!empty($validated['homepage'])) { + } elseif (! empty($validated['homepage'])) { $profileUpdates['website'] = $validated['homepage']; } @@ -872,13 +922,11 @@ class ProfileController extends Controller $month = $validated['month'] ?? null; $year = $validated['year'] ?? null; if ($year && $month && $day) { - $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day); + $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day); } - if (!empty($validated['gender'])) { - $g = strtolower($validated['gender']); - $map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; - $profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']); + if (array_key_exists('gender', $validated)) { + $profileUpdates['gender'] = $this->normalizeProfileGender($validated['gender'] ?? null); } if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) { @@ -902,19 +950,26 @@ class ProfileController extends Controller $profileUpdates['auto_post_upload'] = filter_var($validated['auto_post_upload'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } - if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature']; - if (isset($validated['description'])) $profileUpdates['description'] = $validated['description']; + if (isset($validated['signature'])) { + $profileUpdates['signature'] = $validated['signature']; + } + if (isset($validated['description'])) { + $profileUpdates['description'] = $validated['description']; + } - if (isset($validated['about'])) $profileUpdates['about'] = $validated['about']; + if (isset($validated['about'])) { + $profileUpdates['about'] = $validated['about']; + } if ($request->hasFile('avatar')) { try { $avatarService->storeFromUploadedFile($user->id, $request->file('avatar')); } catch (\Exception $e) { if ($request->expectsJson()) { - return response()->json(['errors' => ['avatar' => ['Avatar processing failed: ' . $e->getMessage()]]], 422); + return response()->json(['errors' => ['avatar' => ['Avatar processing failed: '.$e->getMessage()]]], 422); } - return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage()); + + return Redirect::back()->with('error', 'Avatar processing failed: '.$e->getMessage()); } } @@ -924,11 +979,12 @@ class ProfileController extends Controller $file = $request->file('emoticon'); if (in_array($file->getMimeType(), $allowedImageMimes, true)) { $ext = $file->guessExtension() ?: 'jpg'; - $fname = $user->id . '_emoticon_' . time() . '.' . $ext; - \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname); + $fname = $user->id.'_emoticon_'.time().'.'.$ext; + Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname); try { - \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]); - } catch (\Exception $e) {} + DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]); + } catch (\Exception $e) { + } } } @@ -936,26 +992,27 @@ class ProfileController extends Controller $file = $request->file('photo'); if (in_array($file->getMimeType(), $allowedImageMimes, true)) { $ext = $file->guessExtension() ?: 'jpg'; - $fname = $user->id . '_photo_' . time() . '.' . $ext; - \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); - if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { + $fname = $user->id.'_photo_'.time().'.'.$ext; + Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); + if (Schema::hasTable('user_profiles')) { $profileUpdates['cover_image'] = $fname; } else { try { - \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]); - } catch (\Exception $e) {} + DB::table('users')->where('id', $user->id)->update(['picture' => $fname]); + } catch (\Exception $e) { + } } } } try { - if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { - if (!empty($profileUpdates)) { - \Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates); + if (Schema::hasTable('user_profiles')) { + if (! empty($profileUpdates)) { + DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates); } } else { - if (!empty($profileUpdates)) { - \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates); + if (! empty($profileUpdates)) { + DB::table('users')->where('id', $user->id)->update($profileUpdates); } } } catch (\Exception $e) { @@ -1016,11 +1073,10 @@ class ProfileController extends Controller string $component = 'Profile/ProfileShow', bool $galleryOnly = false, ?string $initialTab = null, - ) - { - $isOwner = Auth::check() && Auth::id() === $user->id; - $viewer = Auth::user(); - $perPage = 24; + ) { + $isOwner = Auth::check() && Auth::id() === $user->id; + $viewer = Auth::user(); + $perPage = 24; // ── Artworks (cursor-paginated) ────────────────────────────────────── $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer) @@ -1044,7 +1100,7 @@ class ProfileController extends Controller ]; } if ($favourites !== null && $favouriteTable !== null) { - $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') @@ -1065,25 +1121,25 @@ class ProfileController extends Controller $favIds = $favIds->take($favouriteLimit); $indexed = Artwork::with([ - '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']); - }, - ]) + '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']); + }, + ]) ->whereIn('id', $favIds) ->get() ->keyBy('id'); $favourites = [ 'data' => $favIds - ->filter(fn ($id) => $indexed->has($id)) - ->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id])) - ->values() - ->all(), + ->filter(fn ($id) => $indexed->has($id)) + ->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id])) + ->values() + ->all(), 'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null, ]; } @@ -1105,8 +1161,8 @@ class ProfileController extends Controller } // ── Follower data ──────────────────────────────────────────────────── - $followerCount = 0; - $recentFollowers = collect(); + $followerCount = 0; + $recentFollowers = collect(); $viewerIsFollowing = false; $followingCount = 0; @@ -1124,11 +1180,11 @@ class ProfileController extends Controller ->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash', 'uf.created_at as followed_at']) ->get() ->map(fn ($row) => (object) [ - 'id' => $row->id, - 'username' => $row->username, - 'uname' => $row->username ?? $row->name, - 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), - 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), + 'id' => $row->id, + 'username' => $row->username, + 'uname' => $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), + 'profile_url' => '/@'.strtolower((string) ($row->username ?? $row->id)), 'followed_at' => $row->followed_at, ]); @@ -1203,14 +1259,14 @@ class ProfileController extends Controller ]) ->get() ->map(fn ($row) => (object) [ - 'id' => $row->id, - 'body' => $row->body, - 'created_at' => $row->created_at, - 'author_id' => $row->author_id, + 'id' => $row->id, + 'body' => $row->body, + 'created_at' => $row->created_at, + 'author_id' => $row->author_id, 'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown', 'author_level' => (int) ($row->author_level ?? 1), 'author_rank' => (string) ($row->author_rank ?? 'Newbie'), - 'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)), + 'author_profile_url' => '/@'.strtolower((string) ($row->author_username ?? $row->author_id)), 'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50), 'author_signature' => $row->author_signature, ]); @@ -1256,8 +1312,11 @@ class ProfileController extends Controller 'published_at' => $story->published_at?->toISOString(), ]); + $profileCollectionsPayload = null; + if ($initialTab === 'collections') { $profileCollections = $this->collections->getProfileCollections($user, $viewer); $profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer); + } // ── Profile data ───────────────────────────────────────────────────── $profile = $user->profile; @@ -1276,15 +1335,16 @@ class ProfileController extends Controller if (! $isOwner) { try { $this->userStats->incrementProfileViews($user->id); - } catch (\Throwable) {} + } catch (\Throwable) { + } } // ── Normalise artworks for JSON serialisation ──────────────────── $artworkItems = collect($artworks->items())->values(); $artworkPayload = [ - 'data' => $artworkItems, + 'data' => $artworkItems, 'next_cursor' => $artworks->nextCursor()?->encode(), - 'has_more' => $artworks->hasMorePages(), + 'has_more' => $artworks->hasMorePages(), ]; // ── Avatar URL on user object ──────────────────────────────────── @@ -1293,24 +1353,24 @@ class ProfileController extends Controller // ── Auth context for JS ─────────────────────────────────────────── $authData = null; if (Auth::check()) { - /** @var \App\Models\User $authUser */ - $authUser = Auth::user(); - $authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64); + /** @var User $authUser */ + $authUser = Auth::user(); + $authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64); $authData = [ 'user' => [ - 'id' => $authUser->id, + 'id' => $authUser->id, 'username' => $authUser->username, - 'name' => $authUser->name, - 'avatar' => $authAvatarUrl, + 'name' => $authUser->name, + 'avatar' => $authAvatarUrl, ], ]; } $usernameSlug = strtolower((string) ($user->username ?? '')); - $canonical = url('/@' . $usernameSlug); - $galleryUrl = url('/@' . $usernameSlug . '/gallery'); + $canonical = url('/@'.$usernameSlug); + $galleryUrl = url('/@'.$usernameSlug.'/gallery'); $profileTabUrls = collect(self::PROFILE_TABS) - ->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)]) + ->mapWithKeys(fn (string $tab) => [$tab => url('/@'.$usernameSlug.'/'.$tab)]) ->all(); $achievementSummary = $this->achievements->summary((int) $user->id); $worldRewardSummary = $this->worldRewards->summaryForUser($user); @@ -1360,17 +1420,17 @@ class ProfileController extends Controller } $pageTitle = $galleryOnly - ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') + ? (($user->username ?? $user->name ?? 'User').' Gallery on Skinbase') : ($isTabLanding - ? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase') - : (($user->username ?? $user->name ?? 'User') . ' on Skinbase')); + ? (($user->username ?? $user->name ?? 'User').' '.$tabMetaLabel.' on Skinbase') + : (($user->username ?? $user->name ?? 'User').' on Skinbase')); $pageDescription = $galleryOnly - ? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') + ? ('Browse the public gallery of '.($user->username ?? $user->name).' on Skinbase.') : ($resolvedInitialTab === 'followers' - ? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.') + ? ('People who follow '.($user->username ?? $user->name).' on Skinbase.') : ($isTabLanding - ? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.') - : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.'))); + ? ('Explore the '.strtolower((string) $tabMetaLabel).' section for '.($user->username ?? $user->name).' on Skinbase.') + : ('View the profile of '.($user->username ?? $user->name).' on Skinbase — artworks, favourites and more.'))); $profileSeo = app(SeoFactory::class)->profilePage( $pageTitle, $galleryOnly ? $galleryUrl : $activeProfileUrl, @@ -1383,72 +1443,72 @@ class ProfileController extends Controller )->toArray(); return Inertia::render($component, [ - 'user' => [ - 'id' => $user->id, - 'username' => $user->username, - 'name' => $user->name, - 'avatar_url' => $avatarUrl, - 'cover_url' => $heroBgUrl, - 'cover_position'=> (int) ($user->cover_position ?? 50), - 'created_at' => $user->created_at?->toISOString(), + 'user' => [ + 'id' => $user->id, + 'username' => $user->username, + 'name' => $user->name, + 'avatar_url' => $avatarUrl, + 'cover_url' => $heroBgUrl, + 'cover_position' => (int) ($user->cover_position ?? 50), + 'created_at' => $user->created_at?->toISOString(), 'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null, - 'xp' => $xpSummary['xp'], - 'level' => $xpSummary['level'], - 'rank' => $xpSummary['rank'], + 'xp' => $xpSummary['xp'], + 'level' => $xpSummary['level'], + 'rank' => $xpSummary['rank'], 'next_level_xp' => $xpSummary['next_level_xp'], 'current_level_xp' => $xpSummary['current_level_xp'], 'progress_percent' => $xpSummary['progress_percent'], 'max_level' => $xpSummary['max_level'], ], - 'profile' => $profile ? [ - 'about' => $profile->about ?? null, - 'website' => $profile->website ?? null, + 'profile' => $profile ? [ + 'about' => $profile->about ?? null, + 'website' => $profile->website ?? null, 'country_code' => $countryCode, - 'gender' => $profile->gender ?? null, - 'birthdate' => $profile->birthdate ?? null, - 'cover_image' => $profile->cover_image ?? null, + 'gender' => $profile->gender ?? null, + 'birthdate' => $profile->birthdate ?? null, + 'cover_image' => $profile->cover_image ?? null, ] : null, - 'artworks' => $artworkPayload, - 'featuredArtworks' => $featuredArtworks?->values(), - 'favourites' => $favourites, - 'stats' => $statsPayload, - 'socialLinks' => $socialLinks, - 'followerCount' => $followerCount, - 'recentFollowers' => $recentFollowers->values(), - 'followersListing' => $followersListing, - 'followContext' => $followContext, - 'followAnalytics' => $followAnalytics, - 'suggestedUsers' => $suggestedUsers, + 'artworks' => $artworkPayload, + 'featuredArtworks' => $featuredArtworks?->values(), + 'favourites' => $favourites, + 'stats' => $statsPayload, + 'socialLinks' => $socialLinks, + 'followerCount' => $followerCount, + 'recentFollowers' => $recentFollowers->values(), + 'followersListing' => $followersListing, + 'followContext' => $followContext, + 'followAnalytics' => $followAnalytics, + 'suggestedUsers' => $suggestedUsers, 'viewerIsFollowing' => $viewerIsFollowing, - 'heroBgUrl' => $heroBgUrl, - 'profileComments' => $profileComments->values(), - 'creatorStories' => $creatorStories->values(), - 'collections' => $profileCollectionsPayload, - 'achievements' => $achievementSummary, - 'worldRewards' => $worldRewardSummary, - 'worldHistory' => $worldHistory, - 'leaderboardRank' => $leaderboardRank, - 'journey' => $journey, + 'heroBgUrl' => $heroBgUrl, + 'profileComments' => $profileComments->values(), + 'creatorStories' => $creatorStories->values(), + 'collections' => $profileCollectionsPayload, + 'achievements' => $achievementSummary, + 'worldRewards' => $worldRewardSummary, + 'worldHistory' => $worldHistory, + 'leaderboardRank' => $leaderboardRank, + 'journey' => $journey, 'groupContributionHistory' => $groupContributionHistory, - 'countryName' => $countryName, - 'isOwner' => $isOwner, - 'auth' => $authData, - 'initialTab' => $resolvedInitialTab, - 'profileUrl' => $canonical, - 'galleryUrl' => $galleryUrl, + 'countryName' => $countryName, + 'isOwner' => $isOwner, + 'auth' => $authData, + 'initialTab' => $resolvedInitialTab, + 'profileUrl' => $canonical, + 'galleryUrl' => $galleryUrl, 'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null, 'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null, 'collectionsFeaturedUrl' => route('collections.featured'), 'collectionFeatureLimit' => (int) config('collections.featured_limit', 3), - 'profileTabUrls' => $profileTabUrls, - 'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]), + 'profileTabUrls' => $profileTabUrls, + 'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]), ])->withViewData([ - 'page_title' => $pageTitle, - 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, + 'page_title' => $pageTitle, + 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, 'page_meta_description' => $pageDescription, - 'og_image' => $avatarUrl, - 'seo' => $profileSeo, - 'useUnifiedSeo' => true, + 'og_image' => $avatarUrl, + 'seo' => $profileSeo, + 'useUnifiedSeo' => true, ]); } @@ -1456,7 +1516,7 @@ class ProfileController extends Controller * Build the canonical featured artwork representation shared by SSR and * the lazy Artworks-tab endpoint. */ - private function loadFeaturedArtworks(User $user, ?User $viewer): ?\Illuminate\Support\Collection + private function loadFeaturedArtworks(User $user, ?User $viewer): ?Collection { if (! Schema::hasTable('artwork_features')) { return collect(); @@ -1509,13 +1569,13 @@ class ProfileController extends Controller private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse { - $baseUrl = url('/@' . strtolower($username) . '/' . $tab); + $baseUrl = url('/@'.strtolower($username).'/'.$tab); $query = $request->query(); unset($query['tab']); if ($query !== []) { - $baseUrl .= '?' . http_build_query($query); + $baseUrl .= '?'.http_build_query($query); } return redirect()->to($baseUrl, 301); @@ -1634,10 +1694,10 @@ class ProfileController extends Controller * Load the same top-three public releases per group as the previous * per-group LIMIT query, while keeping the existing two-column ordering. * - * @param array $groupIds - * @return \Illuminate\Support\Collection> + * @param array $groupIds + * @return Collection> */ - private function loadRecentGroupReleaseTitles(User $user, array $groupIds): \Illuminate\Support\Collection + private function loadRecentGroupReleaseTitles(User $user, array $groupIds): Collection { $eligibleReleases = GroupRelease::query() ->whereIn('group_id', $groupIds) @@ -1686,7 +1746,7 @@ class ProfileController 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; @@ -1696,7 +1756,7 @@ class ProfileController 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 app(ArtworkMaturityService::class)->decoratePayload([ diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index f504a9c4..71f3d7d4 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -18,6 +18,7 @@ import GroupProfileSummary from '../../components/groups/GroupProfileSummary' import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks' import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites' import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory' +import useLazyProfileCollections from '../../lib/useLazyProfileCollections' const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers'] @@ -104,6 +105,11 @@ export default function ProfileShow() { initialWorldHistory: worldHistory, enabled: activeTab === 'worlds' || activeTab === 'achievements', }) + const collectionsState = useLazyProfileCollections({ + username: user?.username || user?.name || '', + initialCollections: collections, + enabled: activeTab === 'collections', + }) const handleTabChange = useCallback((tab) => { if (!VALID_TABS.includes(tab)) return @@ -252,12 +258,15 @@ export default function ProfileShow() { )} {activeTab === 'collections' && ( )} {activeTab === 'about' && ( diff --git a/resources/js/components/profile/tabs/TabCollections.hydration.test.jsx b/resources/js/components/profile/tabs/TabCollections.hydration.test.jsx new file mode 100644 index 00000000..d72f8e56 --- /dev/null +++ b/resources/js/components/profile/tabs/TabCollections.hydration.test.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import { renderToString } from 'react-dom/server' +import { hydrateRoot } from 'react-dom/client' +import { act } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TabCollections from './TabCollections' + +const card = (id, overrides = {}) => ({ + id, + title: `Collection ${id}`, + slug: `collection-${id}`, + type: 'personal', + mode: 'manual', + visibility: 'public', + is_featured: id === 1, + artworks_count: id === 1 ? 12 : 0, + likes_count: id === 1 ? 4 : 0, + followers_count: 0, + collaborators_count: 1, + updated_at: '2026-08-30T09:00:00.000000Z', + featured_at: id === 1 ? '2026-08-29T09:00:00.000000Z' : null, + description_excerpt: id === 1 ? 'A populated card.' : '', + smart_summary: null, + owner: { name: 'Fixture Owner', username: 'fixture', profile_url: '/@fixture/collections' }, + cover_image: id === 1 ? 'https://cdn.example.test/cover.webp' : null, + cover_image_maturity: { should_blur: false, is_mature_effective: false }, + saved: false, + save_url: null, + unsave_url: null, + login_url: '/login', + url: `/@fixture/collections/collection-${id}`, + ...overrides, +}) + +const props = { + collections: [card(1), card(2)], + isOwner: false, + createUrl: null, + reorderUrl: null, + featuredUrl: '/collections/featured', + featureLimit: 3, +} + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('TabCollections SSR hydration', () => { + it('hydrates populated collection cards without markup drift or errors', async () => { + const container = document.createElement('div') + const serverMarkup = renderToString() + container.innerHTML = serverMarkup + const parsedServerMarkup = container.innerHTML + document.body.appendChild(container) + const recoverableErrors = [] + + const root = hydrateRoot(container, , { + onRecoverableError: (error) => recoverableErrors.push(error), + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(container.innerHTML).toBe(parsedServerMarkup) + expect(recoverableErrors).toHaveLength(0) + expect(container.textContent).toContain('Collection 1') + expect(container.textContent).toContain('12 artworks') + + await act(async () => root.unmount()) + }) + + it('hydrates loaded-empty state without a lazy request', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + const emptyProps = { ...props, collections: [] } + const container = document.createElement('div') + const serverMarkup = renderToString() + container.innerHTML = serverMarkup + const parsedServerMarkup = container.innerHTML + document.body.appendChild(container) + const recoverableErrors = [] + + const root = hydrateRoot(container, , { + onRecoverableError: (error) => recoverableErrors.push(error), + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(container.innerHTML).toBe(parsedServerMarkup) + expect(recoverableErrors).toHaveLength(0) + expect(container.textContent).toContain('No public collections yet') + expect(fetchMock).not.toHaveBeenCalled() + + await act(async () => root.unmount()) + }) +}) diff --git a/resources/js/components/profile/tabs/TabCollections.jsx b/resources/js/components/profile/tabs/TabCollections.jsx index 16354648..b375030f 100644 --- a/resources/js/components/profile/tabs/TabCollections.jsx +++ b/resources/js/components/profile/tabs/TabCollections.jsx @@ -50,7 +50,7 @@ async function requestJson(url, { method = 'POST', body } = {}) { const FILTERS = ['all', 'featured', 'smart', 'manual'] -export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) { +export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3, loading = false, error = false, onRetry }) { const [items, setItems] = useState(Array.isArray(collections) ? collections : []) const [busyId, setBusyId] = useState(null) const [filter, setFilter] = useState('all') @@ -140,6 +140,15 @@ export default function TabCollections({ collections, isOwner, createUrl, reorde aria-labelledby="tab-collections" className="pt-6" > + {loading ?

Loading collections…

: null} + {error ? ( +
+ Collections could not be loaded. + {onRetry ? : null} +
+ ) : null} + {!loading && !error ? ( + <>

Collections

@@ -218,6 +227,8 @@ export default function TabCollections({ collections, isOwner, createUrl, reorde ))}
)} + + ) : null}
) } diff --git a/resources/js/lib/useLazyProfileCollections.js b/resources/js/lib/useLazyProfileCollections.js new file mode 100644 index 00000000..618ab097 --- /dev/null +++ b/resources/js/lib/useLazyProfileCollections.js @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export default function useLazyProfileCollections({ username, initialCollections, enabled }) { + const hasInitialValue = Array.isArray(initialCollections) + const [items, setItems] = useState(() => hasInitialValue ? initialCollections : []) + const [loaded, setLoaded] = useState(hasInitialValue) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const requestKeyRef = useRef(username) + const requestedRef = useRef(hasInitialValue) + const abortRef = useRef(null) + + useEffect(() => { + if (requestKeyRef.current === username) return undefined + + abortRef.current?.abort() + abortRef.current = null + requestKeyRef.current = username + requestedRef.current = false + setItems([]) + setLoaded(false) + setLoading(false) + setError(false) + + return undefined + }, [username]) + + useEffect(() => { + if (!hasInitialValue || requestKeyRef.current !== username) return undefined + + abortRef.current?.abort() + abortRef.current = null + requestedRef.current = true + setItems(initialCollections) + setLoaded(true) + setLoading(false) + setError(false) + + return undefined + }, [hasInitialValue, initialCollections, username]) + + const load = useCallback(async () => { + if (!enabled || hasInitialValue || requestKeyRef.current !== username || requestedRef.current || !username) return + + requestedRef.current = true + const controller = new AbortController() + const requestKey = requestKeyRef.current + abortRef.current = controller + setLoading(true) + setError(false) + + try { + const response = await fetch(`/api/profile/${encodeURIComponent(username)}/collections`, { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + }) + + if (!response.ok) throw new Error('Collections could not be loaded.') + + const payload = await response.json() + if (requestKey !== requestKeyRef.current || controller.signal.aborted) return + + setItems(Array.isArray(payload?.data) ? payload.data : []) + setLoaded(true) + } catch (caught) { + if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return + requestedRef.current = false + setError(true) + } finally { + if (requestKey === requestKeyRef.current) { + abortRef.current = null + setLoading(false) + } + } + }, [enabled, hasInitialValue, username]) + + useEffect(() => { + if (enabled) load() + }, [enabled, load]) + + useEffect(() => () => abortRef.current?.abort(), []) + + return { items, loaded, loading, error, retry: load } +} diff --git a/resources/js/lib/useLazyProfileCollections.test.jsx b/resources/js/lib/useLazyProfileCollections.test.jsx new file mode 100644 index 00000000..4ce1a42e --- /dev/null +++ b/resources/js/lib/useLazyProfileCollections.test.jsx @@ -0,0 +1,70 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import useLazyProfileCollections from './useLazyProfileCollections' + +function Harness({ username = 'alice', initial = null, enabled = false }) { + const state = useLazyProfileCollections({ + username, + initialCollections: initial, + enabled, + }) + + return {JSON.stringify(state)} +} + +describe('useLazyProfileCollections', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('does not request while the profile remains on Posts', () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + render() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('requests once on first Collections activation and reuses the result', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 1, title: 'One' }] }), + }) + const view = render() + + view.rerender() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toHaveLength(1)) + + view.rerender() + view.rerender() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('treats an empty response as loaded and does not refetch', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: [] }) }) + const view = render() + + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true)) + view.rerender() + view.rerender() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses direct Collections SSR data without a lazy request', () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + const initial = [{ id: 9, title: 'SSR collection' }] + const view = render() + + expect(fetchMock).not.toHaveBeenCalled() + expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual(initial) + }) + + it('ignores a stale response after profile navigation', async () => { + let resolveAlice + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveAlice = resolve })) + const view = render() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + view.rerender() + resolveAlice({ ok: true, json: async () => ({ data: [{ id: 'alice' }] }) }) + await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([])) + }) +}) diff --git a/tests/Feature/Profile/ProfileCollectionsLazyTest.php b/tests/Feature/Profile/ProfileCollectionsLazyTest.php new file mode 100644 index 00000000..77b1a7fd --- /dev/null +++ b/tests/Feature/Profile/ProfileCollectionsLazyTest.php @@ -0,0 +1,195 @@ +create(['username' => 'lazycollections']); + $collections = Collection::factory()->for($user)->count(3)->sequence( + ['title' => 'First collection', 'profile_order' => 0], + ['title' => 'Featured collection', 'is_featured' => true, 'profile_order' => 1], + ['title' => 'Third collection', 'profile_order' => 2], + )->create(); + + return [$user, $collections]; +} + +it('omits collection production from the default Posts SSR request', function (): void { + [$user] = profileCollectionsLazyFixture(); + $collectionQueries = []; + + DB::listen(function ($query) use (&$collectionQueries): void { + if (preg_match('/(?:from|join) [`"]?(collections|collection_artwork)[`"]?/i', $query->sql)) { + $collectionQueries[] = $query->sql; + } + }); + + $this->get('/@' . $user->username) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', 'posts') + ->where('collections', null)); + + expect($collectionQueries)->toBe([]); +}); + +it('keeps collections in direct Collections SSR with the existing order and limit', function (): void { + [$user, $collections] = profileCollectionsLazyFixture(); + + $this->get('/@' . $user->username . '/collections') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', 'collections') + ->has('collections', 3) + ->where('collections.0.id', $collections[1]->id) + ->where('collections.1.id', $collections[0]->id) + ->where('collections.2.id', $collections[2]->id)); +}); + +it('returns the same profile card payload from the lazy endpoint without tracking a view', function (): void { + [$user] = profileCollectionsLazyFixture(); + $before = (int) ($user->profile_views_count ?? 0); + + $expected = app(\App\Services\CollectionService::class) + ->mapCollectionCardPayloads( + app(\App\Services\CollectionService::class)->getProfileCollections($user, null), + false, + null, + ); + + $this->getJson('/api/profile/' . $user->username . '/collections') + ->assertOk() + ->assertJson(['data' => $expected]); + + expect((int) $user->fresh()->profile_views_count)->toBe($before); +}); + +it('keeps private collections owner-only in the lazy endpoint', function (): void { + [$user] = profileCollectionsLazyFixture(); + Collection::factory()->for($user)->create([ + 'title' => 'Private collection', + 'visibility' => Collection::VISIBILITY_PRIVATE, + ]); + + $this->getJson('/api/profile/' . $user->username . '/collections') + ->assertOk() + ->assertJsonMissing(['title' => 'Private collection']); + + $this->actingAs($user) + ->getJson('/api/profile/' . $user->username . '/collections') + ->assertOk() + ->assertJsonFragment(['title' => 'Private collection']); +}); + +it('preserves the authenticated non-owner payload and viewer state', function (): void { + [$user] = profileCollectionsLazyFixture(); + $viewer = User::factory()->create(['username' => 'collectionviewer']); + $service = app(\App\Services\CollectionService::class); + $expected = $service->mapCollectionCardPayloads( + $service->getProfileCollections($user, $viewer), + false, + $viewer, + ); + + $this->actingAs($viewer) + ->getJson('/api/profile/' . $user->username . '/collections') + ->assertOk() + ->assertJson(['data' => $expected]); +}); + +it('keeps the existing first twelve collection order', function (): void { + $user = User::factory()->create(['username' => 'collectionlimit']); + Collection::factory()->for($user)->count(13)->sequence( + fn ($sequence) => ['profile_order' => $sequence->index], + )->create(); + + $service = app(\App\Services\CollectionService::class); + $expectedIds = $service->getProfileCollections($user, null)->pluck('id')->all(); + + expect($expectedIds)->toHaveCount(12); + $this->getJson('/api/profile/' . $user->username . '/collections') + ->assertOk() + ->assertJsonPath('data.*.id', $expectedIds); +}); + +it('bulk-loads manual collection covers without mapper fan-out', function (): void { + $user = User::factory()->create(['username' => 'collectionmapper']); + $collections = Collection::factory()->for($user)->count(3)->create(); + + foreach ($collections as $index => $collection) { + $artwork = Artwork::factory()->for($user)->create([ + 'title' => 'Collection artwork ' . $index, + ]); + $collection->artworks()->attach($artwork->id, ['order_num' => 1]); + } + + $service = app(\App\Services\CollectionService::class); + $loaded = $service->getProfileCollections($user, null); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $payload = $service->mapCollectionCardPayloads($loaded, false, null); + $queries = DB::getQueryLog(); + + expect($payload)->toHaveCount(3) + ->and($queries)->toHaveCount(2) + ->and(collect($payload)->pluck('cover_artwork_id')->filter()->count())->toBe(3); +}); + +it('keeps manual mapper query counts bounded from one through twelve collections', function (): void { + $user = User::factory()->create(['username' => 'collectionmatrix']); + $collections = Collection::factory()->for($user)->count(12)->create(); + + foreach ($collections as $index => $collection) { + $artwork = Artwork::factory()->for($user)->create(['title' => 'Matrix artwork ' . $index]); + $collection->artworks()->attach($artwork->id, ['order_num' => 1]); + } + + $service = app(\App\Services\CollectionService::class); + $matrix = []; + + foreach ([1, 3, 6, 12] as $size) { + DB::flushQueryLog(); + DB::enableQueryLog(); + $loaded = $service->getProfileCollections($user, null, $size); + $producer = count(DB::getQueryLog()); + + DB::flushQueryLog(); + $service->mapCollectionCardPayloads($loaded, false, null); + $mapper = count(DB::getQueryLog()); + $matrix[$size] = [$producer, $mapper, $producer + $mapper]; + } + + expect($matrix[1][1])->toBe($matrix[3][1]) + ->and($matrix[3][1])->toBe($matrix[6][1]) + ->and($matrix[6][1])->toBe($matrix[12][1]); + +}); + +it('does not alter the smart collection cover and rule-output path', function (): void { + $user = User::factory()->create(['username' => 'smartguard']); + $artwork = Artwork::factory()->for($user)->create(['title' => 'Smart result']); + $collection = Collection::factory()->for($user)->create([ + 'mode' => Collection::MODE_SMART, + 'smart_rules_json' => ['match' => 'all', 'rules' => [['field' => 'is_featured', 'value' => true]]], + ]); + + $smart = Mockery::mock(SmartCollectionService::class); + $smart->shouldReceive('firstArtwork')->once()->withArgs(fn (Collection $actual, bool $ownerView): bool => $actual->is($collection) && $ownerView === false)->andReturn($artwork); + $smart->shouldReceive('smartSummary')->once()->andReturn('smart rule summary'); + app()->instance(SmartCollectionService::class, $smart); + + $payload = app(\App\Services\CollectionService::class)->mapCollectionCardPayloads( + new \Illuminate\Database\Eloquent\Collection([$collection->load('user')]), + false, + null, + ); + + expect($payload[0]['cover_artwork_id'])->toBe($artwork->id) + ->and($payload[0]['smart_summary'])->toBe('smart rule summary'); +});