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\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\CarbonInterface;
|
use App\Services\CollectionService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use App\Support\UsernamePolicy;
|
use App\Support\UsernamePolicy;
|
||||||
use App\Services\Maturity\ArtworkMaturityService;
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -26,6 +27,28 @@ use UnexpectedValueException;
|
|||||||
*/
|
*/
|
||||||
final class ProfileApiController extends Controller
|
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
|
* GET /api/profile/{username}/artworks
|
||||||
* Returns cursor-paginated artworks for the profile page tabs.
|
* 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;
|
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||||
$sort = $request->input('sort', 'latest');
|
$sort = $request->input('sort', 'latest');
|
||||||
|
|
||||||
$query = Artwork::with([
|
$query = Artwork::with([
|
||||||
'user:id,name,username,level,rank',
|
'user:id,name,username,level,rank',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
'group:id,name,slug,avatar_path',
|
'group:id,name,slug,avatar_path',
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
'categories' => function ($query) {
|
'categories' => function ($query) {
|
||||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
->with(['contentType:id,slug,name']);
|
->with(['contentType:id,slug,name']);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->whereNull('artworks.deleted_at');
|
->whereNull('artworks.deleted_at');
|
||||||
|
|
||||||
@@ -85,9 +108,9 @@ final class ProfileApiController extends Controller
|
|||||||
->values();
|
->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||||
'has_more' => $paginator->hasMorePages(),
|
'has_more' => $paginator->hasMorePages(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +133,7 @@ final class ProfileApiController extends Controller
|
|||||||
$perPage = 12;
|
$perPage = 12;
|
||||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
$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')
|
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||||
->where('af.user_id', $user->id)
|
->where('af.user_id', $user->id)
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('a.deleted_at')
|
||||||
@@ -128,22 +151,22 @@ final class ProfileApiController extends Controller
|
|||||||
->pluck('a.id');
|
->pluck('a.id');
|
||||||
|
|
||||||
$hasMore = $favIds->count() > $perPage;
|
$hasMore = $favIds->count() > $perPage;
|
||||||
$favIds = $favIds->take($perPage);
|
$favIds = $favIds->take($perPage);
|
||||||
|
|
||||||
if ($favIds->isEmpty()) {
|
if ($favIds->isEmpty()) {
|
||||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$indexed = Artwork::with([
|
$indexed = Artwork::with([
|
||||||
'user:id,name,username,level,rank',
|
'user:id,name,username,level,rank',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
'group:id,name,slug,avatar_path',
|
'group:id,name,slug,avatar_path',
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
'categories' => function ($query) {
|
'categories' => function ($query) {
|
||||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
->with(['contentType:id,slug,name']);
|
->with(['contentType:id,slug,name']);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->whereIn('id', $favIds)
|
->whereIn('id', $favIds)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
@@ -154,9 +177,9 @@ final class ProfileApiController extends Controller
|
|||||||
->values();
|
->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
'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([
|
return response()->json([
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'follower_count' => $followerCount,
|
'follower_count' => $followerCount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -190,6 +213,7 @@ final class ProfileApiController extends Controller
|
|||||||
private function resolveUser(string $username): ?User
|
private function resolveUser(string $username): ?User
|
||||||
{
|
{
|
||||||
$normalized = UsernamePolicy::normalize($username);
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
|
|
||||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,17 +232,17 @@ final class ProfileApiController extends Controller
|
|||||||
{
|
{
|
||||||
$statsColumn = match ($sort) {
|
$statsColumn = match ($sort) {
|
||||||
'trending' => 'profile_artwork_stats.ranking_score',
|
'trending' => 'profile_artwork_stats.ranking_score',
|
||||||
'rising' => 'profile_artwork_stats.heat_score',
|
'rising' => 'profile_artwork_stats.heat_score',
|
||||||
'views' => 'profile_artwork_stats.views',
|
'views' => 'profile_artwork_stats.views',
|
||||||
'favs' => 'profile_artwork_stats.favorites',
|
'favs' => 'profile_artwork_stats.favorites',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ($statsColumn !== null) {
|
if ($statsColumn !== null) {
|
||||||
return $query
|
return $query
|
||||||
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
|
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
->select('artworks.*')
|
->select('artworks.*')
|
||||||
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
|
->selectRaw('COALESCE('.$statsColumn.', 0) as cursor_sort_value')
|
||||||
->orderByDesc('cursor_sort_value')
|
->orderByDesc('cursor_sort_value')
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
@@ -235,7 +259,7 @@ final class ProfileApiController extends Controller
|
|||||||
private function mapArtworkCardPayload(Artwork $art): array
|
private function mapArtworkCardPayload(Artwork $art): array
|
||||||
{
|
{
|
||||||
$present = ThumbnailPresenter::present($art, 'md');
|
$present = ThumbnailPresenter::present($art, 'md');
|
||||||
$category = $art->categories->first();
|
$category = $art->resolvedPrimaryCategory();
|
||||||
$contentType = $category?->contentType;
|
$contentType = $category?->contentType;
|
||||||
$stats = $art->stats;
|
$stats = $art->stats;
|
||||||
$group = $art->group;
|
$group = $art->group;
|
||||||
@@ -245,7 +269,7 @@ final class ProfileApiController extends Controller
|
|||||||
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||||
$profileUrl = $isGroupPublisher
|
$profileUrl = $isGroupPublisher
|
||||||
? $group->publicUrl()
|
? $group->publicUrl()
|
||||||
: ($username ? '/@' . $username : null);
|
: ($username ? '/@'.$username : null);
|
||||||
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -25,40 +25,43 @@ use App\Models\GroupReleaseContributor;
|
|||||||
use App\Models\ProfileComment;
|
use App\Models\ProfileComment;
|
||||||
use App\Models\Story;
|
use App\Models\Story;
|
||||||
use App\Models\User;
|
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\AchievementService;
|
||||||
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\AvatarService;
|
||||||
use App\Services\CollectionService;
|
use App\Services\CollectionService;
|
||||||
use App\Services\FollowAnalyticsService;
|
|
||||||
use App\Services\LeaderboardService;
|
|
||||||
use App\Services\UserSuggestionService;
|
|
||||||
use App\Services\Countries\CountryCatalogService;
|
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\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\CreatorJourneyService;
|
||||||
use App\Services\Profile\WorldProfileHistoryService;
|
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\UserStatsService;
|
||||||
|
use App\Services\UserSuggestionService;
|
||||||
|
use App\Services\Worlds\WorldRewardService;
|
||||||
|
use App\Services\XPService;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use App\Support\CoverUrl;
|
use App\Support\CoverUrl;
|
||||||
use App\Support\Seo\SeoFactory;
|
use App\Support\Seo\SeoFactory;
|
||||||
use App\Support\UsernamePolicy;
|
use App\Support\UsernamePolicy;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
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\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
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\Validation\Rules\Password as PasswordRule;
|
||||||
|
use Illuminate\View\View;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
@@ -95,9 +98,7 @@ class ProfileController extends Controller
|
|||||||
private readonly CreatorJourneyService $creatorJourney,
|
private readonly CreatorJourneyService $creatorJourney,
|
||||||
private readonly WorldRewardService $worldRewards,
|
private readonly WorldRewardService $worldRewards,
|
||||||
private readonly WorldProfileHistoryService $worldProfileHistory,
|
private readonly WorldProfileHistoryService $worldProfileHistory,
|
||||||
)
|
) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showByUsername(Request $request, string $username)
|
public function showByUsername(Request $request, string $username)
|
||||||
{
|
{
|
||||||
@@ -253,11 +254,11 @@ class ProfileController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = url('/@' . strtolower((string) $user->username) . '/followers');
|
$url = url('/@'.strtolower((string) $user->username).'/followers');
|
||||||
$query = $request->query();
|
$query = $request->query();
|
||||||
|
|
||||||
if ($query !== []) {
|
if ($query !== []) {
|
||||||
$url .= '?' . http_build_query($query);
|
$url .= '?'.http_build_query($query);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->to($url, 301);
|
return redirect()->to($url, 301);
|
||||||
@@ -267,18 +268,18 @@ class ProfileController extends Controller
|
|||||||
public function toggleFollow(Request $request, string $username): JsonResponse
|
public function toggleFollow(Request $request, string $username): JsonResponse
|
||||||
{
|
{
|
||||||
$normalized = UsernamePolicy::normalize($username);
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||||
$actorId = (int) Auth::id();
|
$actorId = (int) Auth::id();
|
||||||
|
|
||||||
if ($actorId === $target->id) {
|
if ($actorId === $target->id) {
|
||||||
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$following = $this->followService->toggle($actorId, (int) $target->id);
|
$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([
|
return response()->json([
|
||||||
'following' => $following,
|
'following' => $following,
|
||||||
'follower_count' => $count,
|
'follower_count' => $count,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -295,8 +296,8 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
$comment = ProfileComment::create([
|
$comment = ProfileComment::create([
|
||||||
'profile_user_id' => $target->id,
|
'profile_user_id' => $target->id,
|
||||||
'author_user_id' => Auth::id(),
|
'author_user_id' => Auth::id(),
|
||||||
'body' => $request->input('body'),
|
'body' => $request->input('body'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile');
|
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();
|
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
|
||||||
if ($profile) {
|
if ($profile) {
|
||||||
$profileData = (array) $profile;
|
$profileData = (array) $profile;
|
||||||
if (isset($profile->website)) $user->homepage = $profile->website;
|
if (isset($profile->website)) {
|
||||||
if (isset($profile->about)) $user->about_me = $profile->about;
|
$user->homepage = $profile->website;
|
||||||
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
|
}
|
||||||
if (isset($profile->gender)) $user->gender = $profile->gender;
|
if (isset($profile->about)) {
|
||||||
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
|
$user->about_me = $profile->about;
|
||||||
if (isset($profile->signature)) $user->signature = $profile->signature;
|
}
|
||||||
if (isset($profile->description)) $user->description = $profile->description;
|
if (isset($profile->birthdate)) {
|
||||||
if (isset($profile->mlist)) $user->mlist = $profile->mlist;
|
$user->birth = $profile->birthdate;
|
||||||
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->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 {
|
try {
|
||||||
$dt = \Carbon\Carbon::parse($user->birth);
|
$dt = Carbon::parse($user->birth);
|
||||||
$birthDay = $dt->format('d');
|
$birthDay = $dt->format('d');
|
||||||
$birthMonth = $dt->format('m');
|
$birthMonth = $dt->format('m');
|
||||||
$birthYear = $dt->format('Y');
|
$birthYear = $dt->format('Y');
|
||||||
} catch (\Throwable $e) {}
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
||||||
@@ -375,7 +398,7 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
// Avatar URL
|
// Avatar URL
|
||||||
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
|
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
|
||||||
$avatarUrl = !empty($avatarHash)
|
$avatarUrl = ! empty($avatarHash)
|
||||||
? AvatarUrl::forUser((int) $user->id, $avatarHash, 256)
|
? AvatarUrl::forUser((int) $user->id, $avatarHash, 256)
|
||||||
: AvatarUrl::default();
|
: AvatarUrl::default();
|
||||||
|
|
||||||
@@ -449,7 +472,7 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
$avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256);
|
$avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256);
|
||||||
|
|
||||||
if (!empty($validated['remove_avatar'])) {
|
if (! empty($validated['remove_avatar'])) {
|
||||||
$avatarService->removeAvatar((int) $user->id);
|
$avatarService->removeAvatar((int) $user->id);
|
||||||
$avatarUrl = AvatarUrl::default();
|
$avatarUrl = AvatarUrl::default();
|
||||||
}
|
}
|
||||||
@@ -631,8 +654,8 @@ class ProfileController extends Controller
|
|||||||
'country_code' => $selectedCountry?->iso2,
|
'country_code' => $selectedCountry?->iso2,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!empty($validated['gender'])) {
|
if (array_key_exists('gender', $validated)) {
|
||||||
$profileUpdates['gender'] = strtoupper((string) $validated['gender']);
|
$profileUpdates['gender'] = $this->normalizeProfileGender($validated['gender'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistProfileUpdates((int) $request->user()->id, $profileUpdates);
|
$this->persistProfileUpdates((int) $request->user()->id, $profileUpdates);
|
||||||
@@ -698,7 +721,7 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
private function persistProfileUpdates(int $userId, array $updates): void
|
private function persistProfileUpdates(int $userId, array $updates): void
|
||||||
{
|
{
|
||||||
if ($updates === [] || !Schema::hasTable('user_profiles')) {
|
if ($updates === [] || ! Schema::hasTable('user_profiles')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +739,29 @@ class ProfileController extends Controller
|
|||||||
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
|
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
|
private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country
|
||||||
{
|
{
|
||||||
if (is_numeric($countryId) && (int) $countryId > 0) {
|
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();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -826,6 +872,7 @@ class ProfileController extends Controller
|
|||||||
if ($request->expectsJson()) {
|
if ($request->expectsJson()) {
|
||||||
return response()->json(['errors' => $error], 422);
|
return response()->json(['errors' => $error], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Redirect::back()->withErrors($error);
|
return Redirect::back()->withErrors($error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +885,7 @@ class ProfileController extends Controller
|
|||||||
if ($request->expectsJson()) {
|
if ($request->expectsJson()) {
|
||||||
return response()->json(['errors' => $error], 422);
|
return response()->json(['errors' => $error], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Redirect::back()->withErrors($error);
|
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 = $validated['email'];
|
||||||
$user->email_verified_at = null;
|
$user->email_verified_at = null;
|
||||||
}
|
}
|
||||||
@@ -860,11 +908,13 @@ class ProfileController extends Controller
|
|||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$profileUpdates = [];
|
$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'];
|
$profileUpdates['website'] = $validated['web'];
|
||||||
} elseif (!empty($validated['homepage'])) {
|
} elseif (! empty($validated['homepage'])) {
|
||||||
$profileUpdates['website'] = $validated['homepage'];
|
$profileUpdates['website'] = $validated['homepage'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,13 +922,11 @@ class ProfileController extends Controller
|
|||||||
$month = $validated['month'] ?? null;
|
$month = $validated['month'] ?? null;
|
||||||
$year = $validated['year'] ?? null;
|
$year = $validated['year'] ?? null;
|
||||||
if ($year && $month && $day) {
|
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'])) {
|
if (array_key_exists('gender', $validated)) {
|
||||||
$g = strtolower($validated['gender']);
|
$profileUpdates['gender'] = $this->normalizeProfileGender($validated['gender'] ?? null);
|
||||||
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
|
|
||||||
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) {
|
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;
|
$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['signature'])) {
|
||||||
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
|
$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')) {
|
if ($request->hasFile('avatar')) {
|
||||||
try {
|
try {
|
||||||
$avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
|
$avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
if ($request->expectsJson()) {
|
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');
|
$file = $request->file('emoticon');
|
||||||
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
||||||
$ext = $file->guessExtension() ?: 'jpg';
|
$ext = $file->guessExtension() ?: 'jpg';
|
||||||
$fname = $user->id . '_emoticon_' . time() . '.' . $ext;
|
$fname = $user->id.'_emoticon_'.time().'.'.$ext;
|
||||||
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
||||||
try {
|
try {
|
||||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
|
DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
|
||||||
} catch (\Exception $e) {}
|
} catch (\Exception $e) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,26 +992,27 @@ class ProfileController extends Controller
|
|||||||
$file = $request->file('photo');
|
$file = $request->file('photo');
|
||||||
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
||||||
$ext = $file->guessExtension() ?: 'jpg';
|
$ext = $file->guessExtension() ?: 'jpg';
|
||||||
$fname = $user->id . '_photo_' . time() . '.' . $ext;
|
$fname = $user->id.'_photo_'.time().'.'.$ext;
|
||||||
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
||||||
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
if (Schema::hasTable('user_profiles')) {
|
||||||
$profileUpdates['cover_image'] = $fname;
|
$profileUpdates['cover_image'] = $fname;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
|
DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
|
||||||
} catch (\Exception $e) {}
|
} catch (\Exception $e) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
if (Schema::hasTable('user_profiles')) {
|
||||||
if (!empty($profileUpdates)) {
|
if (! empty($profileUpdates)) {
|
||||||
\Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates);
|
DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!empty($profileUpdates)) {
|
if (! empty($profileUpdates)) {
|
||||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -1016,11 +1073,10 @@ class ProfileController extends Controller
|
|||||||
string $component = 'Profile/ProfileShow',
|
string $component = 'Profile/ProfileShow',
|
||||||
bool $galleryOnly = false,
|
bool $galleryOnly = false,
|
||||||
?string $initialTab = null,
|
?string $initialTab = null,
|
||||||
)
|
) {
|
||||||
{
|
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
$viewer = Auth::user();
|
||||||
$viewer = Auth::user();
|
$perPage = 24;
|
||||||
$perPage = 24;
|
|
||||||
|
|
||||||
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
||||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer)
|
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer)
|
||||||
@@ -1044,7 +1100,7 @@ class ProfileController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
if ($favourites !== null && $favouriteTable !== null) {
|
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')
|
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||||
->where('af.user_id', $user->id)
|
->where('af.user_id', $user->id)
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('a.deleted_at')
|
||||||
@@ -1065,25 +1121,25 @@ class ProfileController extends Controller
|
|||||||
$favIds = $favIds->take($favouriteLimit);
|
$favIds = $favIds->take($favouriteLimit);
|
||||||
|
|
||||||
$indexed = Artwork::with([
|
$indexed = Artwork::with([
|
||||||
'user:id,name,username,level,rank',
|
'user:id,name,username,level,rank',
|
||||||
'user.profile:user_id,avatar_hash',
|
'user.profile:user_id,avatar_hash',
|
||||||
'group:id,name,slug,avatar_path',
|
'group:id,name,slug,avatar_path',
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
'categories' => function ($query) {
|
'categories' => function ($query) {
|
||||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
->with(['contentType:id,slug,name']);
|
->with(['contentType:id,slug,name']);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->whereIn('id', $favIds)
|
->whereIn('id', $favIds)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
$favourites = [
|
$favourites = [
|
||||||
'data' => $favIds
|
'data' => $favIds
|
||||||
->filter(fn ($id) => $indexed->has($id))
|
->filter(fn ($id) => $indexed->has($id))
|
||||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||||
->values()
|
->values()
|
||||||
->all(),
|
->all(),
|
||||||
'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null,
|
'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1105,8 +1161,8 @@ class ProfileController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Follower data ────────────────────────────────────────────────────
|
// ── Follower data ────────────────────────────────────────────────────
|
||||||
$followerCount = 0;
|
$followerCount = 0;
|
||||||
$recentFollowers = collect();
|
$recentFollowers = collect();
|
||||||
$viewerIsFollowing = false;
|
$viewerIsFollowing = false;
|
||||||
$followingCount = 0;
|
$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'])
|
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash', 'uf.created_at as followed_at'])
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($row) => (object) [
|
->map(fn ($row) => (object) [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'username' => $row->username,
|
'username' => $row->username,
|
||||||
'uname' => $row->username ?? $row->name,
|
'uname' => $row->username ?? $row->name,
|
||||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
'profile_url' => '/@'.strtolower((string) ($row->username ?? $row->id)),
|
||||||
'followed_at' => $row->followed_at,
|
'followed_at' => $row->followed_at,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1203,14 +1259,14 @@ class ProfileController extends Controller
|
|||||||
])
|
])
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($row) => (object) [
|
->map(fn ($row) => (object) [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'body' => $row->body,
|
'body' => $row->body,
|
||||||
'created_at' => $row->created_at,
|
'created_at' => $row->created_at,
|
||||||
'author_id' => $row->author_id,
|
'author_id' => $row->author_id,
|
||||||
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
|
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
|
||||||
'author_level' => (int) ($row->author_level ?? 1),
|
'author_level' => (int) ($row->author_level ?? 1),
|
||||||
'author_rank' => (string) ($row->author_rank ?? 'Newbie'),
|
'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_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
|
||||||
'author_signature' => $row->author_signature,
|
'author_signature' => $row->author_signature,
|
||||||
]);
|
]);
|
||||||
@@ -1256,8 +1312,11 @@ class ProfileController extends Controller
|
|||||||
'published_at' => $story->published_at?->toISOString(),
|
'published_at' => $story->published_at?->toISOString(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$profileCollectionsPayload = null;
|
||||||
|
if ($initialTab === 'collections') {
|
||||||
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
||||||
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer);
|
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Profile data ─────────────────────────────────────────────────────
|
// ── Profile data ─────────────────────────────────────────────────────
|
||||||
$profile = $user->profile;
|
$profile = $user->profile;
|
||||||
@@ -1276,15 +1335,16 @@ class ProfileController extends Controller
|
|||||||
if (! $isOwner) {
|
if (! $isOwner) {
|
||||||
try {
|
try {
|
||||||
$this->userStats->incrementProfileViews($user->id);
|
$this->userStats->incrementProfileViews($user->id);
|
||||||
} catch (\Throwable) {}
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Normalise artworks for JSON serialisation ────────────────────
|
// ── Normalise artworks for JSON serialisation ────────────────────
|
||||||
$artworkItems = collect($artworks->items())->values();
|
$artworkItems = collect($artworks->items())->values();
|
||||||
$artworkPayload = [
|
$artworkPayload = [
|
||||||
'data' => $artworkItems,
|
'data' => $artworkItems,
|
||||||
'next_cursor' => $artworks->nextCursor()?->encode(),
|
'next_cursor' => $artworks->nextCursor()?->encode(),
|
||||||
'has_more' => $artworks->hasMorePages(),
|
'has_more' => $artworks->hasMorePages(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Avatar URL on user object ────────────────────────────────────
|
// ── Avatar URL on user object ────────────────────────────────────
|
||||||
@@ -1293,24 +1353,24 @@ class ProfileController extends Controller
|
|||||||
// ── Auth context for JS ───────────────────────────────────────────
|
// ── Auth context for JS ───────────────────────────────────────────
|
||||||
$authData = null;
|
$authData = null;
|
||||||
if (Auth::check()) {
|
if (Auth::check()) {
|
||||||
/** @var \App\Models\User $authUser */
|
/** @var User $authUser */
|
||||||
$authUser = Auth::user();
|
$authUser = Auth::user();
|
||||||
$authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64);
|
$authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64);
|
||||||
$authData = [
|
$authData = [
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $authUser->id,
|
'id' => $authUser->id,
|
||||||
'username' => $authUser->username,
|
'username' => $authUser->username,
|
||||||
'name' => $authUser->name,
|
'name' => $authUser->name,
|
||||||
'avatar' => $authAvatarUrl,
|
'avatar' => $authAvatarUrl,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||||
$canonical = url('/@' . $usernameSlug);
|
$canonical = url('/@'.$usernameSlug);
|
||||||
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
|
$galleryUrl = url('/@'.$usernameSlug.'/gallery');
|
||||||
$profileTabUrls = collect(self::PROFILE_TABS)
|
$profileTabUrls = collect(self::PROFILE_TABS)
|
||||||
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
|
->mapWithKeys(fn (string $tab) => [$tab => url('/@'.$usernameSlug.'/'.$tab)])
|
||||||
->all();
|
->all();
|
||||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||||
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
|
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
|
||||||
@@ -1360,17 +1420,17 @@ class ProfileController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pageTitle = $galleryOnly
|
$pageTitle = $galleryOnly
|
||||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
? (($user->username ?? $user->name ?? 'User').' Gallery on Skinbase')
|
||||||
: ($isTabLanding
|
: ($isTabLanding
|
||||||
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
|
? (($user->username ?? $user->name ?? 'User').' '.$tabMetaLabel.' on Skinbase')
|
||||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
|
: (($user->username ?? $user->name ?? 'User').' on Skinbase'));
|
||||||
$pageDescription = $galleryOnly
|
$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'
|
: ($resolvedInitialTab === 'followers'
|
||||||
? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
? ('People who follow '.($user->username ?? $user->name).' on Skinbase.')
|
||||||
: ($isTabLanding
|
: ($isTabLanding
|
||||||
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
? ('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.')));
|
: ('View the profile of '.($user->username ?? $user->name).' on Skinbase — artworks, favourites and more.')));
|
||||||
$profileSeo = app(SeoFactory::class)->profilePage(
|
$profileSeo = app(SeoFactory::class)->profilePage(
|
||||||
$pageTitle,
|
$pageTitle,
|
||||||
$galleryOnly ? $galleryUrl : $activeProfileUrl,
|
$galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||||
@@ -1383,72 +1443,72 @@ class ProfileController extends Controller
|
|||||||
)->toArray();
|
)->toArray();
|
||||||
|
|
||||||
return Inertia::render($component, [
|
return Inertia::render($component, [
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'username' => $user->username,
|
'username' => $user->username,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'avatar_url' => $avatarUrl,
|
'avatar_url' => $avatarUrl,
|
||||||
'cover_url' => $heroBgUrl,
|
'cover_url' => $heroBgUrl,
|
||||||
'cover_position'=> (int) ($user->cover_position ?? 50),
|
'cover_position' => (int) ($user->cover_position ?? 50),
|
||||||
'created_at' => $user->created_at?->toISOString(),
|
'created_at' => $user->created_at?->toISOString(),
|
||||||
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
||||||
'xp' => $xpSummary['xp'],
|
'xp' => $xpSummary['xp'],
|
||||||
'level' => $xpSummary['level'],
|
'level' => $xpSummary['level'],
|
||||||
'rank' => $xpSummary['rank'],
|
'rank' => $xpSummary['rank'],
|
||||||
'next_level_xp' => $xpSummary['next_level_xp'],
|
'next_level_xp' => $xpSummary['next_level_xp'],
|
||||||
'current_level_xp' => $xpSummary['current_level_xp'],
|
'current_level_xp' => $xpSummary['current_level_xp'],
|
||||||
'progress_percent' => $xpSummary['progress_percent'],
|
'progress_percent' => $xpSummary['progress_percent'],
|
||||||
'max_level' => $xpSummary['max_level'],
|
'max_level' => $xpSummary['max_level'],
|
||||||
],
|
],
|
||||||
'profile' => $profile ? [
|
'profile' => $profile ? [
|
||||||
'about' => $profile->about ?? null,
|
'about' => $profile->about ?? null,
|
||||||
'website' => $profile->website ?? null,
|
'website' => $profile->website ?? null,
|
||||||
'country_code' => $countryCode,
|
'country_code' => $countryCode,
|
||||||
'gender' => $profile->gender ?? null,
|
'gender' => $profile->gender ?? null,
|
||||||
'birthdate' => $profile->birthdate ?? null,
|
'birthdate' => $profile->birthdate ?? null,
|
||||||
'cover_image' => $profile->cover_image ?? null,
|
'cover_image' => $profile->cover_image ?? null,
|
||||||
] : null,
|
] : null,
|
||||||
'artworks' => $artworkPayload,
|
'artworks' => $artworkPayload,
|
||||||
'featuredArtworks' => $featuredArtworks?->values(),
|
'featuredArtworks' => $featuredArtworks?->values(),
|
||||||
'favourites' => $favourites,
|
'favourites' => $favourites,
|
||||||
'stats' => $statsPayload,
|
'stats' => $statsPayload,
|
||||||
'socialLinks' => $socialLinks,
|
'socialLinks' => $socialLinks,
|
||||||
'followerCount' => $followerCount,
|
'followerCount' => $followerCount,
|
||||||
'recentFollowers' => $recentFollowers->values(),
|
'recentFollowers' => $recentFollowers->values(),
|
||||||
'followersListing' => $followersListing,
|
'followersListing' => $followersListing,
|
||||||
'followContext' => $followContext,
|
'followContext' => $followContext,
|
||||||
'followAnalytics' => $followAnalytics,
|
'followAnalytics' => $followAnalytics,
|
||||||
'suggestedUsers' => $suggestedUsers,
|
'suggestedUsers' => $suggestedUsers,
|
||||||
'viewerIsFollowing' => $viewerIsFollowing,
|
'viewerIsFollowing' => $viewerIsFollowing,
|
||||||
'heroBgUrl' => $heroBgUrl,
|
'heroBgUrl' => $heroBgUrl,
|
||||||
'profileComments' => $profileComments->values(),
|
'profileComments' => $profileComments->values(),
|
||||||
'creatorStories' => $creatorStories->values(),
|
'creatorStories' => $creatorStories->values(),
|
||||||
'collections' => $profileCollectionsPayload,
|
'collections' => $profileCollectionsPayload,
|
||||||
'achievements' => $achievementSummary,
|
'achievements' => $achievementSummary,
|
||||||
'worldRewards' => $worldRewardSummary,
|
'worldRewards' => $worldRewardSummary,
|
||||||
'worldHistory' => $worldHistory,
|
'worldHistory' => $worldHistory,
|
||||||
'leaderboardRank' => $leaderboardRank,
|
'leaderboardRank' => $leaderboardRank,
|
||||||
'journey' => $journey,
|
'journey' => $journey,
|
||||||
'groupContributionHistory' => $groupContributionHistory,
|
'groupContributionHistory' => $groupContributionHistory,
|
||||||
'countryName' => $countryName,
|
'countryName' => $countryName,
|
||||||
'isOwner' => $isOwner,
|
'isOwner' => $isOwner,
|
||||||
'auth' => $authData,
|
'auth' => $authData,
|
||||||
'initialTab' => $resolvedInitialTab,
|
'initialTab' => $resolvedInitialTab,
|
||||||
'profileUrl' => $canonical,
|
'profileUrl' => $canonical,
|
||||||
'galleryUrl' => $galleryUrl,
|
'galleryUrl' => $galleryUrl,
|
||||||
'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null,
|
'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null,
|
||||||
'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null,
|
'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null,
|
||||||
'collectionsFeaturedUrl' => route('collections.featured'),
|
'collectionsFeaturedUrl' => route('collections.featured'),
|
||||||
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
|
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
|
||||||
'profileTabUrls' => $profileTabUrls,
|
'profileTabUrls' => $profileTabUrls,
|
||||||
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
|
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
|
||||||
])->withViewData([
|
])->withViewData([
|
||||||
'page_title' => $pageTitle,
|
'page_title' => $pageTitle,
|
||||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||||
'page_meta_description' => $pageDescription,
|
'page_meta_description' => $pageDescription,
|
||||||
'og_image' => $avatarUrl,
|
'og_image' => $avatarUrl,
|
||||||
'seo' => $profileSeo,
|
'seo' => $profileSeo,
|
||||||
'useUnifiedSeo' => true,
|
'useUnifiedSeo' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1456,7 +1516,7 @@ class ProfileController extends Controller
|
|||||||
* Build the canonical featured artwork representation shared by SSR and
|
* Build the canonical featured artwork representation shared by SSR and
|
||||||
* the lazy Artworks-tab endpoint.
|
* 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')) {
|
if (! Schema::hasTable('artwork_features')) {
|
||||||
return collect();
|
return collect();
|
||||||
@@ -1509,13 +1569,13 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse
|
private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse
|
||||||
{
|
{
|
||||||
$baseUrl = url('/@' . strtolower($username) . '/' . $tab);
|
$baseUrl = url('/@'.strtolower($username).'/'.$tab);
|
||||||
|
|
||||||
$query = $request->query();
|
$query = $request->query();
|
||||||
unset($query['tab']);
|
unset($query['tab']);
|
||||||
|
|
||||||
if ($query !== []) {
|
if ($query !== []) {
|
||||||
$baseUrl .= '?' . http_build_query($query);
|
$baseUrl .= '?'.http_build_query($query);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->to($baseUrl, 301);
|
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
|
* Load the same top-three public releases per group as the previous
|
||||||
* per-group LIMIT query, while keeping the existing two-column ordering.
|
* per-group LIMIT query, while keeping the existing two-column ordering.
|
||||||
*
|
*
|
||||||
* @param array<int, int> $groupIds
|
* @param array<int, int> $groupIds
|
||||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, string>>
|
* @return Collection<int, Collection<int, string>>
|
||||||
*/
|
*/
|
||||||
private function loadRecentGroupReleaseTitles(User $user, array $groupIds): \Illuminate\Support\Collection
|
private function loadRecentGroupReleaseTitles(User $user, array $groupIds): Collection
|
||||||
{
|
{
|
||||||
$eligibleReleases = GroupRelease::query()
|
$eligibleReleases = GroupRelease::query()
|
||||||
->whereIn('group_id', $groupIds)
|
->whereIn('group_id', $groupIds)
|
||||||
@@ -1686,7 +1746,7 @@ class ProfileController extends Controller
|
|||||||
private function mapArtworkCardPayload(Artwork $art): array
|
private function mapArtworkCardPayload(Artwork $art): array
|
||||||
{
|
{
|
||||||
$present = ThumbnailPresenter::present($art, 'md');
|
$present = ThumbnailPresenter::present($art, 'md');
|
||||||
$category = $art->categories->first();
|
$category = $art->resolvedPrimaryCategory();
|
||||||
$contentType = $category?->contentType;
|
$contentType = $category?->contentType;
|
||||||
$stats = $art->stats;
|
$stats = $art->stats;
|
||||||
$group = $art->group;
|
$group = $art->group;
|
||||||
@@ -1696,7 +1756,7 @@ class ProfileController extends Controller
|
|||||||
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||||
$profileUrl = $isGroupPublisher
|
$profileUrl = $isGroupPublisher
|
||||||
? $group->publicUrl()
|
? $group->publicUrl()
|
||||||
: ($username ? '/@' . $username : null);
|
: ($username ? '/@'.$username : null);
|
||||||
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||||
|
|
||||||
return app(ArtworkMaturityService::class)->decoratePayload([
|
return app(ArtworkMaturityService::class)->decoratePayload([
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
|||||||
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
||||||
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
|
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
|
||||||
import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory'
|
import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory'
|
||||||
|
import useLazyProfileCollections from '../../lib/useLazyProfileCollections'
|
||||||
|
|
||||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
||||||
|
|
||||||
@@ -104,6 +105,11 @@ export default function ProfileShow() {
|
|||||||
initialWorldHistory: worldHistory,
|
initialWorldHistory: worldHistory,
|
||||||
enabled: activeTab === 'worlds' || activeTab === 'achievements',
|
enabled: activeTab === 'worlds' || activeTab === 'achievements',
|
||||||
})
|
})
|
||||||
|
const collectionsState = useLazyProfileCollections({
|
||||||
|
username: user?.username || user?.name || '',
|
||||||
|
initialCollections: collections,
|
||||||
|
enabled: activeTab === 'collections',
|
||||||
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback((tab) => {
|
const handleTabChange = useCallback((tab) => {
|
||||||
if (!VALID_TABS.includes(tab)) return
|
if (!VALID_TABS.includes(tab)) return
|
||||||
@@ -252,12 +258,15 @@ export default function ProfileShow() {
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'collections' && (
|
{activeTab === 'collections' && (
|
||||||
<TabCollections
|
<TabCollections
|
||||||
collections={collections}
|
collections={collectionsState.items}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
createUrl={collectionCreateUrl}
|
createUrl={collectionCreateUrl}
|
||||||
reorderUrl={collectionReorderUrl}
|
reorderUrl={collectionReorderUrl}
|
||||||
featuredUrl={collectionsFeaturedUrl}
|
featuredUrl={collectionsFeaturedUrl}
|
||||||
featureLimit={collectionFeatureLimit}
|
featureLimit={collectionFeatureLimit}
|
||||||
|
loading={collectionsState.loading}
|
||||||
|
error={collectionsState.error}
|
||||||
|
onRetry={collectionsState.retry}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'about' && (
|
{activeTab === 'about' && (
|
||||||
|
|||||||
@@ -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(<TabCollections {...props} />)
|
||||||
|
container.innerHTML = serverMarkup
|
||||||
|
const parsedServerMarkup = container.innerHTML
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const recoverableErrors = []
|
||||||
|
|
||||||
|
const root = hydrateRoot(container, <TabCollections {...props} />, {
|
||||||
|
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(<TabCollections {...emptyProps} />)
|
||||||
|
container.innerHTML = serverMarkup
|
||||||
|
const parsedServerMarkup = container.innerHTML
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const recoverableErrors = []
|
||||||
|
|
||||||
|
const root = hydrateRoot(container, <TabCollections {...emptyProps} />, {
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -50,7 +50,7 @@ async function requestJson(url, { method = 'POST', body } = {}) {
|
|||||||
|
|
||||||
const FILTERS = ['all', 'featured', 'smart', 'manual']
|
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 [items, setItems] = useState(Array.isArray(collections) ? collections : [])
|
||||||
const [busyId, setBusyId] = useState(null)
|
const [busyId, setBusyId] = useState(null)
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
@@ -140,6 +140,15 @@ export default function TabCollections({ collections, isOwner, createUrl, reorde
|
|||||||
aria-labelledby="tab-collections"
|
aria-labelledby="tab-collections"
|
||||||
className="pt-6"
|
className="pt-6"
|
||||||
>
|
>
|
||||||
|
{loading ? <p className="py-10 text-center text-sm text-slate-400">Loading collections…</p> : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-5 rounded-[24px] border border-rose-300/20 bg-rose-400/10 px-5 py-4 text-sm text-rose-100">
|
||||||
|
<span>Collections could not be loaded.</span>
|
||||||
|
{onRetry ? <button type="button" onClick={onRetry} className="ml-3 underline">Retry</button> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
|
||||||
@@ -218,6 +227,8 @@ export default function TabCollections({ collections, isOwner, createUrl, reorde
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 <output data-testid="state">{JSON.stringify(state)}</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLazyProfileCollections', () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('does not request while the profile remains on Posts', () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
render(<Harness />)
|
||||||
|
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(<Harness />)
|
||||||
|
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toHaveLength(1))
|
||||||
|
|
||||||
|
view.rerender(<Harness enabled={false} />)
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
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(<Harness enabled />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
|
||||||
|
view.rerender(<Harness enabled={false} />)
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
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(<Harness initial={initial} enabled />)
|
||||||
|
|
||||||
|
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(<Harness username="alice" enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
view.rerender(<Harness username="bob" enabled />)
|
||||||
|
resolveAlice({ ok: true, json: async () => ({ data: [{ id: 'alice' }] }) })
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([]))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Collection;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\SmartCollectionService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
|
||||||
|
function profileCollectionsLazyFixture(): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user