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:
@@ -5,12 +5,10 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\FollowService;
|
use App\Services\FollowService;
|
||||||
use App\Support\AvatarUrl;
|
|
||||||
use App\Support\UsernamePolicy;
|
use App\Support\UsernamePolicy;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API endpoints for the follow system.
|
* API endpoints for the follow system.
|
||||||
@@ -72,26 +70,17 @@ final class FollowController extends Controller
|
|||||||
{
|
{
|
||||||
$target = $this->resolveUser($username);
|
$target = $this->resolveUser($username);
|
||||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
|
||||||
$rows = DB::table('user_followers as uf')
|
$rows = $this->followService
|
||||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
->paginatedFollowers((int) $target->id, $perPage, $page)
|
||||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
->through(fn (array $row) => [
|
||||||
->where('uf.user_id', $target->id)
|
'id' => $row['id'],
|
||||||
->whereNull('u.deleted_at')
|
'username' => $row['username'],
|
||||||
->orderByDesc('uf.created_at')
|
'display_name' => $row['display_name'],
|
||||||
->select([
|
'avatar_url' => $row['avatar_url'],
|
||||||
'u.id', 'u.username', 'u.name',
|
'profile_url' => $row['profile_url'],
|
||||||
'up.avatar_hash',
|
'followed_at' => $row['followed_at'],
|
||||||
'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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($rows);
|
return response()->json($rows);
|
||||||
@@ -103,26 +92,17 @@ final class FollowController extends Controller
|
|||||||
{
|
{
|
||||||
$target = $this->resolveUser($username);
|
$target = $this->resolveUser($username);
|
||||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
|
||||||
$rows = DB::table('user_followers as uf')
|
$rows = $this->followService
|
||||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
->paginatedFollowing((int) $target->id, $perPage, $page)
|
||||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
->through(fn (array $row) => [
|
||||||
->where('uf.follower_id', $target->id)
|
'id' => $row['id'],
|
||||||
->whereNull('u.deleted_at')
|
'username' => $row['username'],
|
||||||
->orderByDesc('uf.created_at')
|
'display_name' => $row['display_name'],
|
||||||
->select([
|
'avatar_url' => $row['avatar_url'],
|
||||||
'u.id', 'u.username', 'u.name',
|
'profile_url' => $row['profile_url'],
|
||||||
'up.avatar_hash',
|
'followed_at' => $row['followed_at'],
|
||||||
'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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($rows);
|
return response()->json($rows);
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class ProfileController extends Controller
|
|||||||
'stats',
|
'stats',
|
||||||
'favourites',
|
'favourites',
|
||||||
'activity',
|
'activity',
|
||||||
|
'followers',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -200,6 +201,28 @@ class ProfileController extends Controller
|
|||||||
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
|
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). */
|
/** Toggle follow/unfollow for the profile of $username (auth required). */
|
||||||
public function toggleFollow(Request $request, string $username): JsonResponse
|
public function toggleFollow(Request $request, string $username): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -1293,9 +1316,37 @@ class ProfileController extends Controller
|
|||||||
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
|
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
|
||||||
: $canonical;
|
: $canonical;
|
||||||
$tabMetaLabel = $resolvedInitialTab !== null
|
$tabMetaLabel = $resolvedInitialTab !== null
|
||||||
? ucfirst($resolvedInitialTab)
|
? ($resolvedInitialTab === 'followers' ? 'Followers' : ucfirst($resolvedInitialTab))
|
||||||
: null;
|
: 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
|
$pageTitle = $galleryOnly
|
||||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||||
: ($isTabLanding
|
: ($isTabLanding
|
||||||
@@ -1303,9 +1354,11 @@ class ProfileController extends Controller
|
|||||||
: (($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.')
|
||||||
: ($isTabLanding
|
: ($resolvedInitialTab === 'followers'
|
||||||
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.'));
|
: ($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(
|
$profileSeo = app(SeoFactory::class)->profilePage(
|
||||||
$pageTitle,
|
$pageTitle,
|
||||||
$galleryOnly ? $galleryUrl : $activeProfileUrl,
|
$galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||||
@@ -1350,6 +1403,7 @@ class ProfileController extends Controller
|
|||||||
'socialLinks' => $socialLinks,
|
'socialLinks' => $socialLinks,
|
||||||
'followerCount' => $followerCount,
|
'followerCount' => $followerCount,
|
||||||
'recentFollowers' => $recentFollowers->values(),
|
'recentFollowers' => $recentFollowers->values(),
|
||||||
|
'followersListing' => $followersListing,
|
||||||
'followContext' => $followContext,
|
'followContext' => $followContext,
|
||||||
'followAnalytics' => $followAnalytics,
|
'followAnalytics' => $followAnalytics,
|
||||||
'suggestedUsers' => $suggestedUsers,
|
'suggestedUsers' => $suggestedUsers,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Services\Activity\UserActivityService;
|
|||||||
use App\Events\Achievements\AchievementCheckRequested;
|
use App\Events\Achievements\AchievementCheckRequested;
|
||||||
use App\Services\FollowAnalyticsService;
|
use App\Services\FollowAnalyticsService;
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -160,6 +161,38 @@ final class FollowService
|
|||||||
->value('followers_count');
|
->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
|
public function followingCount(int $userId): int
|
||||||
{
|
{
|
||||||
return (int) DB::table('user_statistics')
|
return (int) DB::table('user_statistics')
|
||||||
@@ -216,6 +249,58 @@ final class FollowService
|
|||||||
|
|
||||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
// ─── 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
|
private function incrementCounter(int $userId, string $column): void
|
||||||
{
|
{
|
||||||
DB::table('user_statistics')->updateOrInsert(
|
DB::table('user_statistics')->updateOrInsert(
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import TabActivity from '../../components/profile/tabs/TabActivity'
|
|||||||
import TabPosts from '../../components/profile/tabs/TabPosts'
|
import TabPosts from '../../components/profile/tabs/TabPosts'
|
||||||
import TabStories from '../../components/profile/tabs/TabStories'
|
import TabStories from '../../components/profile/tabs/TabStories'
|
||||||
import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
||||||
|
import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
||||||
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||||
|
|
||||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity']
|
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
||||||
|
|
||||||
function getInitialTab(initialTab = 'posts') {
|
function getInitialTab(initialTab = 'posts') {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -55,6 +56,7 @@ export default function ProfileShow() {
|
|||||||
socialLinks,
|
socialLinks,
|
||||||
followerCount,
|
followerCount,
|
||||||
recentFollowers,
|
recentFollowers,
|
||||||
|
followersListing,
|
||||||
followContext,
|
followContext,
|
||||||
followAnalytics,
|
followAnalytics,
|
||||||
suggestedUsers,
|
suggestedUsers,
|
||||||
@@ -87,6 +89,15 @@ export default function ProfileShow() {
|
|||||||
|
|
||||||
const handleTabChange = useCallback((tab) => {
|
const handleTabChange = useCallback((tab) => {
|
||||||
if (!VALID_TABS.includes(tab)) return
|
if (!VALID_TABS.includes(tab)) return
|
||||||
|
|
||||||
|
// Followers listing is loaded only on the followers tab GET. A client-only
|
||||||
|
// pushState would render an empty list, so navigate for a real page load.
|
||||||
|
if (tab === 'followers') {
|
||||||
|
const targetBase = profileTabUrls?.followers || `${profileUrl || ''}/followers`
|
||||||
|
window.location.assign(targetBase)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -274,6 +285,13 @@ export default function ProfileShow() {
|
|||||||
creatorStories={creatorStories}
|
creatorStories={creatorStories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'followers' && (
|
||||||
|
<TabFollowers
|
||||||
|
listing={followersListing}
|
||||||
|
username={user.username || user.name}
|
||||||
|
followerCount={followerCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const TABS = [
|
|||||||
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
||||||
{ id: 'favourites', label: 'Favourites', icon: 'fa-heart' },
|
{ id: 'favourites', label: 'Favourites', icon: 'fa-heart' },
|
||||||
{ id: 'activity', label: 'Activity', icon: 'fa-bolt' },
|
{ id: 'activity', label: 'Activity', icon: 'fa-bolt' },
|
||||||
|
{ id: 'followers', label: 'Followers', icon: 'fa-users' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
function formatFollowedAt(value) {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabFollowers({ listing, username, followerCount }) {
|
||||||
|
const items = Array.isArray(listing?.data) ? listing.data : []
|
||||||
|
const total = Number(listing?.total ?? followerCount ?? 0)
|
||||||
|
const currentPage = Number(listing?.current_page ?? 1)
|
||||||
|
const lastPage = Number(listing?.last_page ?? 1)
|
||||||
|
const prevUrl = listing?.prev_page_url ?? null
|
||||||
|
const nextUrl = listing?.next_page_url ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="tabpanel-followers"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-followers"
|
||||||
|
className="pt-6"
|
||||||
|
>
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||||
|
<i className="fa-solid fa-users text-sky-400 fa-fw" />
|
||||||
|
Followers
|
||||||
|
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-medium normal-case tracking-normal text-slate-400">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400">
|
||||||
|
{username ? `${username} has no public followers yet.` : 'No public followers yet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{items.map((follower) => {
|
||||||
|
const handle = follower.username || follower.display_name || follower.name
|
||||||
|
const followedAt = formatFollowedAt(follower.followed_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={follower.id}>
|
||||||
|
<a
|
||||||
|
href={follower.profile_url}
|
||||||
|
className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-3 transition-colors hover:border-sky-300/25 hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
{follower.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={follower.avatar_url}
|
||||||
|
alt=""
|
||||||
|
className="h-12 w-12 rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/5 text-slate-500">
|
||||||
|
<i className="fa-solid fa-user" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold text-white">
|
||||||
|
{handle}
|
||||||
|
</span>
|
||||||
|
{follower.username ? (
|
||||||
|
<span className="block truncate text-xs text-slate-400">@{follower.username}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="mt-1 block text-[11px] text-slate-500">
|
||||||
|
{[follower.rank, followedAt ? `Followed ${followedAt}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastPage > 1 ? (
|
||||||
|
<nav className="mt-6 flex items-center justify-center gap-3 text-sm" aria-label="Followers pagination">
|
||||||
|
{prevUrl ? (
|
||||||
|
<a href={prevUrl} className="rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-xl border border-white/5 px-3 py-2 text-slate-600">Previous</span>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-500">
|
||||||
|
Page {currentPage} of {lastPage}
|
||||||
|
</span>
|
||||||
|
{nextUrl ? (
|
||||||
|
<a href={nextUrl} className="rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-xl border border-white/5 px-3 py-2 text-slate-600">Next</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -556,7 +556,7 @@
|
|||||||
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
|
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
|
||||||
{{ number_format($followerCount) }}
|
{{ number_format($followerCount) }}
|
||||||
</span>
|
</span>
|
||||||
<a href="/following/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}"
|
<a href="{{ url('/@' . strtolower((string) ($user->username ?? $uname)) . '/followers') }}"
|
||||||
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
|
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
|
||||||
All
|
All
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+1
-1
@@ -493,7 +493,7 @@ Route::get('/@{username}/collections/{slug}', [ProfileCollectionController::clas
|
|||||||
|
|
||||||
Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername'])
|
Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername'])
|
||||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||||
->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity')
|
->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity|followers')
|
||||||
->name('profile.tab');
|
->name('profile.tab');
|
||||||
|
|
||||||
Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
|
Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
|
||||||
|
function insertFollow(int $ownerId, int $followerId, ?\DateTimeInterface $at = null): void
|
||||||
|
{
|
||||||
|
DB::table('user_followers')->insert([
|
||||||
|
'user_id' => $ownerId,
|
||||||
|
'follower_id' => $followerId,
|
||||||
|
'created_at' => $at ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the public profile followers page for an existing username', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'followowner']);
|
||||||
|
$follower = User::factory()->create(['username' => 'realfollower']);
|
||||||
|
$ownerFollows = User::factory()->create(['username' => 'ownerfollows']);
|
||||||
|
|
||||||
|
insertFollow((int) $owner->id, (int) $follower->id);
|
||||||
|
insertFollow((int) $ownerFollows->id, (int) $owner->id);
|
||||||
|
|
||||||
|
$this->get('/@followowner/followers')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('http://skinbase26.test/@followowner/followers', false)
|
||||||
|
->assertSee('followowner Followers on Skinbase', false)
|
||||||
|
->assertSee('People who follow followowner on Skinbase.', false)
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Profile/ProfileShow')
|
||||||
|
->where('initialTab', 'followers')
|
||||||
|
->where('profileTabUrls.followers', url('/@followowner/followers'))
|
||||||
|
->where('followersListing.total', 1)
|
||||||
|
->where('followersListing.data.0.username', 'realfollower')
|
||||||
|
->where('followersListing.data.0.profile_url', '/@realfollower')
|
||||||
|
->missing('followersListing.data.0.email')
|
||||||
|
->has('profileTabUrls.posts')
|
||||||
|
->has('profileTabUrls.artworks'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows anonymous viewers to load public followers without authentication', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'anonowner']);
|
||||||
|
|
||||||
|
$this->get('/@anonowner/followers')->assertOk();
|
||||||
|
$this->assertGuest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return people the owner follows as followers', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'semanticowner']);
|
||||||
|
$follower = User::factory()->create(['username' => 'semanticfollower']);
|
||||||
|
$following = User::factory()->create(['username' => 'semanticfollowing']);
|
||||||
|
|
||||||
|
insertFollow((int) $owner->id, (int) $follower->id);
|
||||||
|
insertFollow((int) $following->id, (int) $owner->id);
|
||||||
|
|
||||||
|
$this->get('/@semanticowner/followers')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('followersListing.total', 1)
|
||||||
|
->where('followersListing.data.0.username', 'semanticfollower')
|
||||||
|
->where('followersListing.data.0.username', fn ($username) => $username !== 'semanticfollowing'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes mixed-case usernames to the lowercase canonical followers URL', function () {
|
||||||
|
User::factory()->create(['username' => 'CaseOwner']);
|
||||||
|
|
||||||
|
$this->get('/@CaseOwner/followers')
|
||||||
|
->assertStatus(301)
|
||||||
|
->assertRedirect('/@caseowner/followers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows username_redirects to the current followers URL', function () {
|
||||||
|
$user = User::factory()->create(['username' => 'newfollowuser']);
|
||||||
|
|
||||||
|
DB::table('username_redirects')->insert([
|
||||||
|
'old_username' => 'oldfollowuser',
|
||||||
|
'new_username' => 'newfollowuser',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/@oldfollowuser/followers')
|
||||||
|
->assertStatus(301)
|
||||||
|
->assertRedirect('/@newfollowuser/followers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a nonexistent public followers username', function () {
|
||||||
|
$this->get('/@missinguserzz/followers')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates public followers', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'pageowner']);
|
||||||
|
$followers = User::factory()->count(25)->create();
|
||||||
|
|
||||||
|
foreach ($followers as $index => $follower) {
|
||||||
|
insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->get('/@pageowner/followers')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('followersListing.per_page', 24)
|
||||||
|
->where('followersListing.current_page', 1)
|
||||||
|
->where('followersListing.last_page', 2)
|
||||||
|
->where('followersListing.total', 25)
|
||||||
|
->has('followersListing.data', 24));
|
||||||
|
|
||||||
|
$this->get('/@pageowner/followers?page=2')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('followersListing.current_page', 2)
|
||||||
|
->has('followersListing.data', 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects legacy following id and slug to the canonical followers URL', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'legacyfollow']);
|
||||||
|
|
||||||
|
$this->get('/following/' . $owner->id . '/old-outdated-slug')
|
||||||
|
->assertStatus(301)
|
||||||
|
->assertRedirect('/@legacyfollow/followers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mismatched legacy slug and uses the current database username', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'canonicalname']);
|
||||||
|
|
||||||
|
$this->get('/following/' . $owner->id . '/wrong-slug')
|
||||||
|
->assertStatus(301)
|
||||||
|
->assertRedirect('/@canonicalname/followers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a legacy following URL when the user id does not exist', function () {
|
||||||
|
$this->get('/following/99999999/whatever')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves query strings when redirecting legacy following URLs', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'qsowner']);
|
||||||
|
|
||||||
|
$this->get('/following/' . $owner->id . '/old-slug?page=2&utm_source=legacy')
|
||||||
|
->assertStatus(301)
|
||||||
|
->assertRedirect('/@qsowner/followers?page=2&utm_source=legacy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no longer emits the broken legacy following URL from the old profile template', function () {
|
||||||
|
$blade = file_get_contents(resource_path('views/_legacy/profile.blade.php'));
|
||||||
|
|
||||||
|
expect($blade)->not->toContain('/following/{{ $user->id }}');
|
||||||
|
expect($blade)->toContain("/@' . strtolower((string) (\$user->username ?? \$uname)) . '/followers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves authenticated /following root behavior unchanged', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/following')
|
||||||
|
->assertRedirect(route('dashboard.following'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves dashboard followers as the authenticated owner surface', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/dashboard/followers')
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves studio followers as the creator-owner surface', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/studio/followers')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioFollowers'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the public followers API payload shape unchanged', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'apiowner']);
|
||||||
|
$follower = User::factory()->create(['username' => 'apifollower']);
|
||||||
|
$following = User::factory()->create(['username' => 'apifollowing']);
|
||||||
|
|
||||||
|
insertFollow((int) $owner->id, (int) $follower->id);
|
||||||
|
insertFollow((int) $following->id, (int) $owner->id);
|
||||||
|
|
||||||
|
$this->getJson('/api/user/apiowner/followers')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.username', 'apifollower')
|
||||||
|
->assertJsonPath('data.0.profile_url', '/@apifollower')
|
||||||
|
->assertJsonMissingPath('data.0.email')
|
||||||
|
->assertJsonMissing(['username' => 'apifollowing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves other public profile tabs on their canonical routes', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'tabowner']);
|
||||||
|
|
||||||
|
$this->get('/@tabowner/artworks')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', 'artworks')
|
||||||
|
->where('profileTabUrls.followers', url('/@tabowner/followers'))
|
||||||
|
->where('profileTabUrls.artworks', url('/@tabowner/artworks')));
|
||||||
|
});
|
||||||
|
|
||||||
|
function profileSqlLooksLikePaginatedFollowers(string $sql): bool
|
||||||
|
{
|
||||||
|
$normalized = strtolower($sql);
|
||||||
|
|
||||||
|
if (! str_contains($normalized, 'user_followers')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($normalized, ' as uf') && ! str_contains($normalized, ' as "uf"')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isPaginatorCount = str_contains($normalized, 'aggregate')
|
||||||
|
&& str_contains($normalized, 'join');
|
||||||
|
|
||||||
|
$isPageRows = str_contains($normalized, 'limit')
|
||||||
|
&& (str_contains($normalized, 'limit 24') || str_contains($normalized, 'limit ?'));
|
||||||
|
|
||||||
|
return $isPaginatorCount || $isPageRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not load paginated followers on the base profile, artworks, or about tabs', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'lazyowner']);
|
||||||
|
$followers = User::factory()->count(3)->create();
|
||||||
|
|
||||||
|
foreach ($followers as $index => $follower) {
|
||||||
|
insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['/@lazyowner', '/@lazyowner/artworks', '/@lazyowner/about'] as $url) {
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
$this->get($url)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('followersListing', null)
|
||||||
|
->has('followerCount')
|
||||||
|
->has('recentFollowers')
|
||||||
|
->has('viewerIsFollowing')
|
||||||
|
->has('followAnalytics'));
|
||||||
|
|
||||||
|
$paginatedQueries = collect(DB::getQueryLog())
|
||||||
|
->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query']))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($paginatedQueries)->toBeEmpty('paginated followers query ran on '.$url);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::disableQueryLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads paginated followers only on the followers tab including page=2', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'lazyfollow']);
|
||||||
|
$followers = User::factory()->count(25)->create();
|
||||||
|
|
||||||
|
foreach ($followers as $index => $follower) {
|
||||||
|
insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index));
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
$this->get('/@lazyfollow/followers?page=2')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', 'followers')
|
||||||
|
->where('followersListing.current_page', 2)
|
||||||
|
->where('followersListing.per_page', 24)
|
||||||
|
->where('followersListing.total', 25)
|
||||||
|
->has('followersListing.data', 1)
|
||||||
|
->has('followerCount')
|
||||||
|
->has('recentFollowers'));
|
||||||
|
|
||||||
|
$paginatedQueries = collect(DB::getQueryLog())
|
||||||
|
->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query']))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($paginatedQueries)->not->toBeEmpty();
|
||||||
|
|
||||||
|
DB::disableQueryLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores ?page= on unrelated profile tabs and still skips followers pagination', function () {
|
||||||
|
$owner = User::factory()->create(['username' => 'pageignore']);
|
||||||
|
insertFollow((int) $owner->id, (int) User::factory()->create()->id);
|
||||||
|
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
$this->get('/@pageignore/artworks?page=2')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', 'artworks')
|
||||||
|
->where('followersListing', null));
|
||||||
|
|
||||||
|
$paginatedQueries = collect(DB::getQueryLog())
|
||||||
|
->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query']))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($paginatedQueries)->toBeEmpty();
|
||||||
|
|
||||||
|
DB::disableQueryLog();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user