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:
test
2026-09-20 14:49:33 +02:00
parent 1d18052fde
commit 37100ced3f
8 changed files with 770 additions and 217 deletions
@@ -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 [
+242 -182
View File
@@ -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<int, int> $groupIds
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, string>>
* @param array<int, int> $groupIds
* @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()
->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([