From d8de0a18e55378a794d4eb4564cce18b52de0062 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sun, 31 May 2026 10:43:30 +0530 Subject: [PATCH] feat: add user search --- app/api/search/users/route.ts | 49 ++++++ app/search/search-content.tsx | 241 ++++++++++++++++++++++++++- app/u/[handle]/page.tsx | 67 +++----- app/u/[handle]/posts-grid.tsx | 60 +++++++ app/u/[handle]/posts/page.tsx | 84 ++-------- app/u/[handle]/posts/scroll-feed.tsx | 78 +++++++++ app/u/[handle]/profile-og.ts | 47 ++++++ 7 files changed, 504 insertions(+), 122 deletions(-) create mode 100644 app/api/search/users/route.ts create mode 100644 app/u/[handle]/posts-grid.tsx create mode 100644 app/u/[handle]/posts/scroll-feed.tsx create mode 100644 app/u/[handle]/profile-og.ts diff --git a/app/api/search/users/route.ts b/app/api/search/users/route.ts new file mode 100644 index 00000000..dce03571 --- /dev/null +++ b/app/api/search/users/route.ts @@ -0,0 +1,49 @@ +/** + * Same-origin proxy for the user-search endpoint. + * + * Pattern mirrors /api/search (content search) — the browser can't talk + * to BFF_URL directly (server-only env var; no NEXT_PUBLIC_ prefix), so + * the search page fetches this same-origin route and we proxy through + * to /api/v1/users/search server-side. Returned payload is sanitized + * by the BFF (handle / name / avatar_url only — no email, role, plan). + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BFF_BASE = process.env.BFF_URL || "http://localhost:8080"; + +type EmptyShape = { + query: string; + results: Array<{ handle: string; name: string; avatar_url: string | null }>; +}; + +const empty = (q: string): EmptyShape => ({ query: q, results: [] }); + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const q = (searchParams.get("q") || "").trim(); + const limit = searchParams.get("limit") || "20"; + + if (!q) { + return NextResponse.json(empty("")); + } + + try { + const url = new URL("/api/v1/users/search", BFF_BASE); + url.searchParams.set("q", q); + url.searchParams.set("limit", limit); + + const res = await fetch(url.toString(), { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + return NextResponse.json(empty(q), { status: res.status }); + } + const data = await res.json(); + // Unwrap BFF envelope: { data: { query, results } } → { query, results } + const payload = data.data ?? data; + return NextResponse.json(payload); + } catch { + return NextResponse.json(empty(q), { status: 502 }); + } +} diff --git a/app/search/search-content.tsx b/app/search/search-content.tsx index d1e8fdbe..e47cf3d6 100644 --- a/app/search/search-content.tsx +++ b/app/search/search-content.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; +import Image from "next/image"; import Link from "next/link"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -23,8 +24,24 @@ import { ChevronLeft, ChevronRight, WholeWord, + AtSign, } from "lucide-react"; +// --------------------------------------------------------------------------- +// User search types +// --------------------------------------------------------------------------- + +interface UserResult { + handle: string; + name: string; + avatar_url?: string | null; +} + +interface UserSearchResponse { + query: string; + results: UserResult[]; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -203,15 +220,33 @@ function SearchInner() { const urlTypeParam = selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(","); const [data, setData] = useState(null); + // Separate state slot for user-search results — keeps the content + // search response shape clean and lets the two branches render + // entirely different cards without a discriminated-union dance. + const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(false); const debouncedQuery = useDebouncedValue(inputValue, 300); const isQueryEmpty = !debouncedQuery.trim(); + // User-search mode is driven entirely by the input — when the query + // starts with "@" we strip it and hit /api/search/users instead of + // the regular content search. The Users chip is a UI shortcut that + // toggles the "@" prefix on / off; it never holds independent state. + const isUserSearch = debouncedQuery.trimStart().startsWith("@"); + const userQuery = isUserSearch + ? debouncedQuery.trimStart().slice(1).trim() + : ""; + const isUserQueryEmpty = isUserSearch && userQuery.length === 0; + // When the query is empty, derive these as "no results" during render // instead of setting state inside an effect. - const effectiveData = isQueryEmpty ? null : data; - const hasSearched = isQueryEmpty ? false : !!debouncedQuery.trim(); + const effectiveData = + isQueryEmpty || isUserSearch ? null : data; + const hasSearched = + isUserSearch + ? !isUserQueryEmpty + : !isQueryEmpty; // Sync URL when state changes const pushUrl = useCallback( @@ -244,10 +279,14 @@ function SearchInner() { setCurrentPage(1); } - // Fetch search results + // Fetch search results — branches between user search (`@`-prefixed) + // and content search. We clear the *other* state slot on every run + // so the UI doesn't show stale results from a previous mode. useEffect(() => { const query = debouncedQuery.trim(); - if (!query) { + + // Empty query in either mode — push a clean URL and bail. + if (!query || isUserQueryEmpty) { pushUrl("", urlTypeParam, 1, exactMatch); return; } @@ -257,6 +296,30 @@ function SearchInner() { const controller = new AbortController(); + if (isUserSearch) { + // 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" }); + fetch(`/api/search/users?${userParams.toString()}`, { + signal: controller.signal, + }) + .then((res) => res.json()) + .then((json: UserSearchResponse) => { + setUserData({ ...json, results: json.results ?? [] }); + setIsLoading(false); + }) + .catch((err) => { + if (err.name !== "AbortError") { + setIsLoading(false); + setUserData({ query: userQuery, results: [] }); + } + }); + return () => controller.abort(); + } + + // Content-search branch (unchanged from before — verbatim). + setUserData(null); const params = new URLSearchParams({ q: query, type: activeTypeParam, @@ -288,13 +351,40 @@ function SearchInner() { }); return () => controller.abort(); - }, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, exactMatch, pushUrl]); + }, [ + debouncedQuery, + activeTypeParam, + urlTypeParam, + currentPage, + exactMatch, + pushUrl, + isUserSearch, + isUserQueryEmpty, + userQuery, + ]); // Auto-focus the input on mount useEffect(() => { inputRef.current?.focus(); }, []); + // Users chip is "active" whenever the live input starts with "@". + // Clicking it toggles the prefix on / off — no separate state slot + // since the input is already the source of truth. + const isUserChipActive = inputValue.trimStart().startsWith("@"); + const toggleUsersChip = () => { + const trimmed = inputValue.trimStart(); + if (trimmed.startsWith("@")) { + // Strip the @ prefix (and the surrounding whitespace it carried). + setInputValue(trimmed.slice(1)); + } else { + // Prepend @ — preserves whatever the user has already typed. + setInputValue("@" + trimmed); + } + // Focus the input so the cursor lands ready for typing. + inputRef.current?.focus(); + }; + const handleTypeChange = (type: string) => { if (type === "all") { // "All" clears all individual selections @@ -385,17 +475,53 @@ function SearchInner() { {/* Content Type Tabs */}
+ {/* Users chip — toggles the "@" prefix on the input. Sits at + the start of the row so its at-sign icon reads as a kind + of "search mode" indicator. */} + + {CONTENT_TABS.map((tab) => { - const active = isTypeActive(tab.value); + const active = !isUserChipActive && isTypeActive(tab.value); return (
)} - {/* Results */} - {!isLoading && effectiveData && effectiveData.results.length > 0 && ( + {/* Results (content search only — user-search has its own + block above with avatar-flavoured cards) */} + {!isLoading && + !isUserSearch && + effectiveData && + effectiveData.results.length > 0 && ( <> {/* Results count + top pagination */}
@@ -687,6 +870,46 @@ function ResultMeta({ result }: { result: SearchResult }) { ); } +// --------------------------------------------------------------------------- +// User result card +// --------------------------------------------------------------------------- + +function UserResultCard({ user }: { user: UserResult }) { + const initial = user.name?.charAt(0)?.toUpperCase() || "?"; + return ( + + {user.avatar_url ? ( + {user.name} + ) : ( +
+ {initial} +
+ )} +
+

+ {user.name} +

+

+ @{user.handle} +

+
+ + ); +} + // --------------------------------------------------------------------------- // Exported wrapper with Suspense boundary // --------------------------------------------------------------------------- diff --git a/app/u/[handle]/page.tsx b/app/u/[handle]/page.tsx index 7b07493e..3de43b05 100644 --- a/app/u/[handle]/page.tsx +++ b/app/u/[handle]/page.tsx @@ -1,55 +1,28 @@ -"use client"; - /** - * Public profile — Posts tab (default). + * /u/[handle] — public profile, Posts tab (default). * - * The /u/[handle] layout owns the ProfileHeader + tab strip + container; - * this page only renders the post grid. Tapping a thumbnail jumps to - * /u/[handle]/posts#post- where the Instagram-style scroll feed - * shows the full passages. + * Server shell that exists for two reasons: + * + * 1. Export `generateMetadata` so direct shares of this URL + * (https://prabhupadasays.org/u/) get a rich OG card + * pointing at /api/og/profile/. The layout and the body + * below are client components; only this file is server. + * 2. Mount the client tree (), which reads the + * ProfileContext provided by the parent layout for the post-grid + * hrefs and renders the feed. */ -import { use, useEffect, useState } from "react"; -import { profile, type Post } from "@/lib/social"; -import { PostViews } from "@/components/social/post-views"; -import { usePublicProfile } from "./profile-context"; +import type { Metadata } from "next"; +import { PostsGrid } from "./posts-grid"; +import { metadataForHandle } from "./profile-og"; -export default function PublicProfilePostsPage(props: { - params: Promise<{ handle: string }>; -}) { - const { handle } = use(props.params); - const data = usePublicProfile(); - const [feed, setFeed] = useState([]); - const [loading, setLoading] = useState(true); +type Props = { params: Promise<{ handle: string }> }; - useEffect(() => { - let cancelled = false; - setLoading(true); - profile - .feed(handle, 30) - .then((rows) => { - if (!cancelled) setFeed(rows ?? []); - }) - .catch(() => { - // Layout already 404'd if the handle is unknown; if the feed - // itself fails we just show an empty grid rather than blocking - // the page with an error toast. - }) - .finally(() => !cancelled && setLoading(false)); - return () => { - cancelled = true; - }; - }, [handle]); +export async function generateMetadata({ params }: Props): Promise { + const { handle } = await params; + return metadataForHandle(handle); +} - if (loading) { - return

Loading…

; - } - - return ( - `/u/${data.handle}/posts#post-${p.id}`} - emptyMessage="No posts yet — quotes shared by this user will appear here." - /> - ); +export default function PublicProfilePage({ params }: Props) { + return ; } diff --git a/app/u/[handle]/posts-grid.tsx b/app/u/[handle]/posts-grid.tsx new file mode 100644 index 00000000..5af04399 --- /dev/null +++ b/app/u/[handle]/posts-grid.tsx @@ -0,0 +1,60 @@ +"use client"; + +/** + * PostsGrid — client-side body for /u/[handle] (Posts tab). + * + * Split out from page.tsx so the page itself can be a server component + * that exports `generateMetadata` for the public-profile OG card. The + * /u/[handle]/layout.tsx still owns ProfileHeader + tab strip + the + * outer max-w-3xl container; this grid just renders the post grid + * inside that shell. Tapping a thumbnail jumps to + * /u/[handle]/posts#post- where the Instagram-style scroll feed + * shows the full passages. + */ + +import { use, useEffect, useState } from "react"; +import { profile, type Post } from "@/lib/social"; +import { PostViews } from "@/components/social/post-views"; +import { usePublicProfile } from "./profile-context"; + +export function PostsGrid({ + params, +}: { + params: Promise<{ handle: string }>; +}) { + const { handle } = use(params); + const data = usePublicProfile(); + const [feed, setFeed] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + profile + .feed(handle, 30) + .then((rows) => { + if (!cancelled) setFeed(rows ?? []); + }) + .catch(() => { + // Layout already 404'd if the handle is unknown; if the feed + // itself fails we just show an empty grid rather than blocking + // the page with an error toast. + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [handle]); + + if (loading) { + return

Loading…

; + } + + return ( + `/u/${data.handle}/posts#post-${p.id}`} + emptyMessage="No posts yet — quotes shared by this user will appear here." + /> + ); +} diff --git a/app/u/[handle]/posts/page.tsx b/app/u/[handle]/posts/page.tsx index 01a3b6fa..9bc69647 100644 --- a/app/u/[handle]/posts/page.tsx +++ b/app/u/[handle]/posts/page.tsx @@ -1,76 +1,28 @@ -"use client"; - /** * /u/[handle]/posts — public Instagram-style scroll feed. * - * Mirrors /u/me/posts but read-only (no Delete / Update background). The - * grid thumbnail on /u/[handle] links here with a `#post-` hash so - * the feed scrolls to the tapped post on mount. + * Server shell so we can export `generateMetadata` for OG previews on + * direct shares of this URL. We re-use the same profile-OG metadata as + * /u/[handle] — the post anchor (#post-) that may be appended to + * the URL is a client-side fragment and never reaches server-rendered + * metadata, so we can't surface post-level OG here. * - * The parent /u/[handle]/layout owns the ProfileHeader + tab strip + the - * outer max-w-3xl container, so this page only needs the inner scroll - * column. The max-w-xl cap matches /u/me/posts so the column reads at - * the same width on desktop; on mobile both pages fall back to viewport - * width with the layout-supplied gutters. + * (Post-level OG cards still work via the SharePostSheet → short link + * → /share/[code] route, which DOES see the hash and dispatches to + * `ogForPost` in share-og.ts.) */ -import { use, useEffect, useRef, useState } from "react"; -import { notFound } from "next/navigation"; -import { profile, SocialError, type Post } from "@/lib/social"; -import { PostScrollCard } from "@/components/social/post-scroll-card"; +import type { Metadata } from "next"; +import { metadataForHandle } from "../profile-og"; +import { ScrollFeed } from "./scroll-feed"; -export default function PublicPostsFeedPage(props: { - params: Promise<{ handle: string }>; -}) { - const { handle } = use(props.params); - const [feed, setFeed] = useState([]); - const [loading, setLoading] = useState(true); - const [missing, setMissing] = useState(false); - const scrolledToHashRef = useRef(false); +type Props = { params: Promise<{ handle: string }> }; - useEffect(() => { - let cancelled = false; - setLoading(true); - profile - .feed(handle, 60) - .then((rows) => { - if (cancelled) return; - setFeed(rows ?? []); - }) - .catch((err) => { - if (err instanceof SocialError && err.status === 404) { - setMissing(true); - } - }) - .finally(() => !cancelled && setLoading(false)); - return () => { - cancelled = true; - }; - }, [handle]); +export async function generateMetadata({ params }: Props): Promise { + const { handle } = await params; + return metadataForHandle(handle); +} - useEffect(() => { - if (loading || feed.length === 0 || scrolledToHashRef.current) return; - if (typeof window === "undefined") return; - const hash = window.location.hash; - if (!hash) return; - scrolledToHashRef.current = true; - requestAnimationFrame(() => { - const el = document.getElementById(hash.slice(1)); - if (el) el.scrollIntoView({ block: "start", behavior: "auto" }); - }); - }, [loading, feed.length]); - - if (missing) notFound(); - - return ( -
- {loading ? ( -

Loading…

- ) : feed.length === 0 ? ( -

No posts yet.

- ) : ( - feed.map((p) => ) - )} -
- ); +export default function PublicPostsFeedPage({ params }: Props) { + return ; } diff --git a/app/u/[handle]/posts/scroll-feed.tsx b/app/u/[handle]/posts/scroll-feed.tsx new file mode 100644 index 00000000..b6791f50 --- /dev/null +++ b/app/u/[handle]/posts/scroll-feed.tsx @@ -0,0 +1,78 @@ +"use client"; + +/** + * ScrollFeed — client body for /u/[handle]/posts (public scroll feed). + * + * Split out from page.tsx so the page itself can be a server component + * exporting `generateMetadata` for the OG card. The layout owns the + * ProfileHeader + tabs + outer container; this component only renders + * the inner scroll column (the Instagram-style feed of full posts). + * + * Hash-anchor behaviour: when arriving with `#post-`, we scroll to + * that post once the feed has loaded. Used by the SharePostSheet flow + * — share short URL resolves to /u//posts#post-, recipient + * lands here, and we scroll to the post that was actually shared. + */ + +import { use, useEffect, useRef, useState } from "react"; +import { notFound } from "next/navigation"; +import { profile, SocialError, type Post } from "@/lib/social"; +import { PostScrollCard } from "@/components/social/post-scroll-card"; + +export function ScrollFeed({ + params, +}: { + params: Promise<{ handle: string }>; +}) { + const { handle } = use(params); + const [feed, setFeed] = useState([]); + const [loading, setLoading] = useState(true); + const [missing, setMissing] = useState(false); + const scrolledToHashRef = useRef(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + profile + .feed(handle, 60) + .then((rows) => { + if (cancelled) return; + setFeed(rows ?? []); + }) + .catch((err) => { + if (err instanceof SocialError && err.status === 404) { + setMissing(true); + } + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [handle]); + + useEffect(() => { + if (loading || feed.length === 0 || scrolledToHashRef.current) return; + if (typeof window === "undefined") return; + const hash = window.location.hash; + if (!hash) return; + scrolledToHashRef.current = true; + requestAnimationFrame(() => { + const el = document.getElementById(hash.slice(1)); + if (el) el.scrollIntoView({ block: "start", behavior: "auto" }); + }); + }, [loading, feed.length]); + + if (missing) notFound(); + + return ( +
+ {loading ? ( +

Loading…

+ ) : feed.length === 0 ? ( +

No posts yet.

+ ) : ( + feed.map((p) => ) + )} +
+ ); +} diff --git a/app/u/[handle]/profile-og.ts b/app/u/[handle]/profile-og.ts new file mode 100644 index 00000000..5702f290 --- /dev/null +++ b/app/u/[handle]/profile-og.ts @@ -0,0 +1,47 @@ +/** + * Shared `generateMetadata` helper for /u/[handle] routes. + * + * Both `/u/[handle]/page.tsx` (Posts tab) and `/u/[handle]/posts/page.tsx` + * (scroll feed) want the same profile-flavoured OG card on direct URL + * shares — the difference between the two is purely the rendered body, + * not the social preview. This helper fetches the profile via the + * server-side BFF client and hands it to the canonical `ogForProfile` + * builder. + * + * Failures (404 handle, BFF unreachable, malformed payload) fall back + * to the site-level `ogDefault` so unfurlers still get a valid card + * rather than nothing. + * + * Note: the page's underlying `app/u/[handle]/layout.tsx` is a client + * component and can't export `generateMetadata`. The metadata has to + * live on the (server) page that mounts the client tree, which is why + * we slim each page.tsx down to a server shell + a separate client + * content file. + */ + +import type { Metadata } from "next"; +import { bff } from "@/lib/bff"; +import { ogDefault, ogForProfile } from "@/lib/og"; + +type PublicProfileForOg = { + handle: string; + name: string; + post_count?: number; +}; + +export async function metadataForHandle(handle: string): Promise { + try { + const profile = await bff( + `/u/${encodeURIComponent(handle)}`, + { revalidate: 60 }, + ); + if (!profile) return ogDefault(); + return ogForProfile({ + handle: profile.handle, + name: profile.name, + postCount: profile.post_count, + }); + } catch { + return ogDefault(); + } +}