diff --git a/app/(app)/search/search-content.tsx b/app/(app)/search/search-content.tsx index bacbd4d3..03f9b5d9 100644 --- a/app/(app)/search/search-content.tsx +++ b/app/(app)/search/search-content.tsx @@ -42,6 +42,10 @@ interface UserResult { interface UserSearchResponse { query: string; results: UserResult[]; + total: number; + page: number; + limit: number; + total_pages: number; } // --------------------------------------------------------------------------- @@ -307,7 +311,11 @@ function SearchInner() { // User-search branch — strip the @ prefix server-side too. // Clear content data so we don't flash old content results. setData(null); - const userParams = new URLSearchParams({ q: userQuery, limit: "20" }); + const userParams = new URLSearchParams({ + q: userQuery, + page: String(currentPage), + limit: "20", + }); fetch(`/api/search/users?${userParams.toString()}`, { signal: controller.signal, }) @@ -319,7 +327,14 @@ function SearchInner() { .catch((err) => { if (err.name !== "AbortError") { setIsLoading(false); - setUserData({ query: userQuery, results: [] }); + setUserData({ + query: userQuery, + results: [], + total: 0, + page: 1, + limit: 20, + total_pages: 0, + }); } }); return () => controller.abort(); @@ -540,16 +555,40 @@ function SearchInner() { userData && userData.results.length > 0 && ( <> -

- {userData.results.length}{" "} - {userData.results.length === 1 ? "user" : "users"} matching - “@{userQuery}” -

+ {/* Match count + top pagination */} +
+

+ {userData.total.toLocaleString()}{" "} + {userData.total === 1 ? "user" : "users"} matching “@ + {userQuery}” +

+ {userData.total_pages > 1 && ( + + )} +
+
{userData.results.map((u) => ( ))}
+ + {/* Bottom pagination */} + {userData.total_pages > 1 && ( +
+ +
+ )} )} diff --git a/app/(app)/u/[handle]/layout.tsx b/app/(app)/u/[handle]/layout.tsx index 79304ae3..667a0aa9 100644 --- a/app/(app)/u/[handle]/layout.tsx +++ b/app/(app)/u/[handle]/layout.tsx @@ -98,6 +98,43 @@ export default function PublicProfileLayout(props: { ); } + // A block in either direction hides this profile's content. The backend + // already refuses the feed/mentions/followers endpoints with 403, so this + // is the matching client state: keep the header (its action row carries the + // Unblock button when *you* blocked them) but replace the tabs + feed with + // a notice instead of a misleading empty grid. + const rel = data.relationship; + const youBlocked = !!rel?.blocked; + const theyBlocked = !!rel?.blocked_by; + const blocked = youBlocked || theyBlocked; + + if (blocked) { + return ( +
+ +
+

+ {youBlocked + ? `You've blocked @${data.handle}. Unblock them to see their posts.` + : `@${data.handle}'s posts aren't available.`} +

+
+
+ ); + } + const tabs: ProfileTab[] = [ { href: `/u/${data.handle}`, diff --git a/app/api/search/users/route.ts b/app/api/search/users/route.ts index dce03571..3687a2e4 100644 --- a/app/api/search/users/route.ts +++ b/app/api/search/users/route.ts @@ -15,14 +15,26 @@ const BFF_BASE = process.env.BFF_URL || "http://localhost:8080"; type EmptyShape = { query: string; results: Array<{ handle: string; name: string; avatar_url: string | null }>; + total: number; + page: number; + limit: number; + total_pages: number; }; -const empty = (q: string): EmptyShape => ({ query: q, results: [] }); +const empty = (q: string): EmptyShape => ({ + query: q, + results: [], + total: 0, + page: 1, + limit: 20, + total_pages: 0, +}); export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const q = (searchParams.get("q") || "").trim(); const limit = searchParams.get("limit") || "20"; + const page = searchParams.get("page") || "1"; if (!q) { return NextResponse.json(empty("")); @@ -32,6 +44,7 @@ export async function GET(request: NextRequest) { const url = new URL("/api/v1/users/search", BFF_BASE); url.searchParams.set("q", q); url.searchParams.set("limit", limit); + url.searchParams.set("page", page); const res = await fetch(url.toString(), { headers: { Accept: "application/json" }, diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 32566970..57e89de2 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -51,6 +51,7 @@ export function ProfileHeader({ relationship, enableActions = false, country, + interactiveCounts = true, }: { avatarURL: string | null; name: string; @@ -75,6 +76,10 @@ export function ProfileHeader({ * this header with key={handle} so the live count + relationship state * below re-seeds cleanly when navigating between profiles. */ enableActions?: boolean; + /** When false, the follower/following stats render as plain text (no + * click target) and the follow-list drawer isn't mounted — used on a + * blocked profile, where the lists endpoint is gated server-side. */ + interactiveCounts?: boolean; }) { // Owner affordances ride on `editHref`; the follow-action row rides on // `enableActions`. Mutually exclusive by construction. @@ -128,12 +133,20 @@ export function ProfileHeader({ setFollowList("followers")} + onClick={ + interactiveCounts + ? () => setFollowList("followers") + : undefined + } /> setFollowList("following")} + onClick={ + interactiveCounts + ? () => setFollowList("following") + : undefined + } /> ) : null} @@ -181,7 +194,7 @@ export function ProfileHeader({ onClose={() => setZoomOpen(false)} /> ) : null} - {showCounts ? ( + {showCounts && interactiveCounts ? (