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 ? (