Add a public profile followers tab.

Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
This commit is contained in:
2026-08-29 12:25:49 +02:00
parent 1d350c9bca
commit bf9ca12469
9 changed files with 606 additions and 47 deletions
+20 -40
View File
@@ -5,12 +5,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* API endpoints for the follow system.
@@ -72,26 +70,17 @@ final class FollowController extends Controller
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.user_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $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,
$rows = $this->followService
->paginatedFollowers((int) $target->id, $perPage, $page)
->through(fn (array $row) => [
'id' => $row['id'],
'username' => $row['username'],
'display_name' => $row['display_name'],
'avatar_url' => $row['avatar_url'],
'profile_url' => $row['profile_url'],
'followed_at' => $row['followed_at'],
]);
return response()->json($rows);
@@ -103,26 +92,17 @@ final class FollowController extends Controller
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.follower_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $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,
$rows = $this->followService
->paginatedFollowing((int) $target->id, $perPage, $page)
->through(fn (array $row) => [
'id' => $row['id'],
'username' => $row['username'],
'display_name' => $row['display_name'],
'avatar_url' => $row['avatar_url'],
'profile_url' => $row['profile_url'],
'followed_at' => $row['followed_at'],
]);
return response()->json($rows);
@@ -74,6 +74,7 @@ class ProfileController extends Controller
'stats',
'favourites',
'activity',
'followers',
];
public function __construct(
@@ -200,6 +201,28 @@ class ProfileController extends Controller
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
}
/**
* Legacy "All followers" URL from old profile templates:
* GET /following/{id}/{slug?} → /@{canonical-username}/followers
*/
public function legacyFollowingById(Request $request, int $id, ?string $slug = null)
{
$user = User::query()->find($id);
if (! $user) {
abort(404);
}
$url = url('/@' . strtolower((string) $user->username) . '/followers');
$query = $request->query();
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
return redirect()->to($url, 301);
}
/** Toggle follow/unfollow for the profile of $username (auth required). */
public function toggleFollow(Request $request, string $username): JsonResponse
{
@@ -1293,9 +1316,37 @@ class ProfileController extends Controller
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
: $canonical;
$tabMetaLabel = $resolvedInitialTab !== null
? ucfirst($resolvedInitialTab)
? ($resolvedInitialTab === 'followers' ? 'Followers' : ucfirst($resolvedInitialTab))
: null;
$followersListing = null;
if (! $galleryOnly && $resolvedInitialTab === 'followers') {
$followersPage = max(1, (int) $request->query('page', 1));
$followersListing = [
'data' => [],
'current_page' => $followersPage,
'last_page' => 1,
'per_page' => 24,
'total' => 0,
'next_page_url' => null,
'prev_page_url' => null,
];
if (Schema::hasTable('user_followers')) {
$followersPaginator = $this->followService->paginatedFollowers((int) $user->id, 24, $followersPage);
$followersListing = [
'data' => array_values($followersPaginator->items()),
'current_page' => $followersPaginator->currentPage(),
'last_page' => $followersPaginator->lastPage(),
'per_page' => $followersPaginator->perPage(),
'total' => $followersPaginator->total(),
'next_page_url' => $followersPaginator->nextPageUrl(),
'prev_page_url' => $followersPaginator->previousPageUrl(),
];
}
}
$pageTitle = $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: ($isTabLanding
@@ -1303,9 +1354,11 @@ class ProfileController extends Controller
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
$pageDescription = $galleryOnly
? ('Browse the public gallery of ' . ($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.'));
: ($resolvedInitialTab === 'followers'
? ('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.')));
$profileSeo = app(SeoFactory::class)->profilePage(
$pageTitle,
$galleryOnly ? $galleryUrl : $activeProfileUrl,
@@ -1350,6 +1403,7 @@ class ProfileController extends Controller
'socialLinks' => $socialLinks,
'followerCount' => $followerCount,
'recentFollowers' => $recentFollowers->values(),
'followersListing' => $followersListing,
'followContext' => $followContext,
'followAnalytics' => $followAnalytics,
'suggestedUsers' => $suggestedUsers,
+85
View File
@@ -7,6 +7,7 @@ use App\Services\Activity\UserActivityService;
use App\Events\Achievements\AchievementCheckRequested;
use App\Services\FollowAnalyticsService;
use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -160,6 +161,38 @@ final class FollowService
->value('followers_count');
}
/**
* People who follow $userId (user_id = the profile owner being followed).
*
* @return LengthAwarePaginator<int, array<string, mixed>>
*/
public function paginatedFollowers(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator
{
return $this->paginateFollowRelationship(
ownerColumn: 'uf.user_id',
relatedUserColumn: 'uf.follower_id',
ownerId: $userId,
perPage: $perPage,
page: $page,
);
}
/**
* People $userId follows (follower_id = the profile owner).
*
* @return LengthAwarePaginator<int, array<string, mixed>>
*/
public function paginatedFollowing(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator
{
return $this->paginateFollowRelationship(
ownerColumn: 'uf.follower_id',
relatedUserColumn: 'uf.user_id',
ownerId: $userId,
perPage: $perPage,
page: $page,
);
}
public function followingCount(int $userId): int
{
return (int) DB::table('user_statistics')
@@ -216,6 +249,58 @@ final class FollowService
// ─── Private helpers ─────────────────────────────────────────────────────
/**
* @return LengthAwarePaginator<int, array<string, mixed>>
*/
private function paginateFollowRelationship(
string $ownerColumn,
string $relatedUserColumn,
int $ownerId,
int $perPage,
int $page,
): LengthAwarePaginator {
$perPage = max(1, min($perPage, 100));
$page = max(1, $page);
return DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', $relatedUserColumn)
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where($ownerColumn, $ownerId)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id',
'u.username',
'u.name',
'u.level',
'u.rank',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage, ['*'], 'page', $page)
->through(fn ($row) => $this->mapPublicFollowUser($row));
}
/**
* @return array<string, mixed>
*/
private function mapPublicFollowUser(object $row): array
{
$username = (string) ($row->username ?? '');
return [
'id' => (int) $row->id,
'username' => $username,
'name' => (string) ($row->name ?? $username),
'display_name' => $username !== '' ? $username : (string) ($row->name ?? ''),
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 50),
'profile_url' => '/@' . strtolower($username !== '' ? $username : (string) $row->id),
'followed_at' => $row->followed_at,
'level' => (int) ($row->level ?? 1),
'rank' => (string) ($row->rank ?? 'Newbie'),
];
}
private function incrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')->updateOrInsert(