From c9b8bd7b071ffa6899d2dd91cdef2cf6e74445d1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 7 Sep 2025 12:54:39 +0200 Subject: [PATCH] feat: implement search functionality with results display, add search input component, and update API for search results --- thoughts-frontend/app/search/page.tsx | 76 +++++++++++++++++++ thoughts-frontend/components/main-nav.tsx | 2 + .../components/post-thought-form.tsx | 2 +- thoughts-frontend/components/search-input.tsx | 29 +++++++ thoughts-frontend/components/thought-list.tsx | 47 ++++++++++++ thoughts-frontend/components/ui/input.tsx | 2 +- thoughts-frontend/lib/api.ts | 15 ++++ 7 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 thoughts-frontend/app/search/page.tsx create mode 100644 thoughts-frontend/components/search-input.tsx create mode 100644 thoughts-frontend/components/thought-list.tsx diff --git a/thoughts-frontend/app/search/page.tsx b/thoughts-frontend/app/search/page.tsx new file mode 100644 index 0000000..99d9296 --- /dev/null +++ b/thoughts-frontend/app/search/page.tsx @@ -0,0 +1,76 @@ +import { cookies } from "next/headers"; +import { getMe, search, User } from "@/lib/api"; +import { UserListCard } from "@/components/user-list-card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ThoughtList } from "@/components/thought-list"; + +interface SearchPageProps { + searchParams: { q?: string }; +} + +export default async function SearchPage({ searchParams }: SearchPageProps) { + const query = searchParams.q || ""; + const token = (await cookies()).get("auth_token")?.value ?? null; + + if (!query) { + return ( +
+

Search Thoughts

+

+ Find users and thoughts across the platform. +

+
+ ); + } + + const [results, me] = await Promise.all([ + search(query, token).catch(() => null), + token ? getMe(token).catch(() => null) : null, + ]); + + const authorDetails = new Map(); + if (results) { + results.users.users.forEach((user: User) => { + authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); + }); + } + + return ( +
+
+

Search Results

+

+ Showing results for: "{query}" +

+
+
+ {results ? ( + + + + Thoughts ({results.thoughts.thoughts.length}) + + + Users ({results.users.users.length}) + + + + + + + + + + ) : ( +

+ No results found or an error occurred. +

+ )} +
+
+ ); +} diff --git a/thoughts-frontend/components/main-nav.tsx b/thoughts-frontend/components/main-nav.tsx index a29058f..22bbbf7 100644 --- a/thoughts-frontend/components/main-nav.tsx +++ b/thoughts-frontend/components/main-nav.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; +import { SearchInput } from "./search-input"; export function MainNav() { const pathname = usePathname(); @@ -17,6 +18,7 @@ export function MainNav() { > Feed + ); } diff --git a/thoughts-frontend/components/post-thought-form.tsx b/thoughts-frontend/components/post-thought-form.tsx index 97ce98c..b103faf 100644 --- a/thoughts-frontend/components/post-thought-form.tsx +++ b/thoughts-frontend/components/post-thought-form.tsx @@ -46,7 +46,7 @@ export function PostThoughtForm() { toast.success("Your thought has been posted!"); form.reset(); router.refresh(); // This is the key to updating the feed - } catch (err) { + } catch { toast.error("Failed to post thought. Please try again."); } } diff --git a/thoughts-frontend/components/search-input.tsx b/thoughts-frontend/components/search-input.tsx new file mode 100644 index 0000000..c3bdda7 --- /dev/null +++ b/thoughts-frontend/components/search-input.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Input } from "./ui/input"; +import { Search as SearchIcon } from "lucide-react"; + +export function SearchInput() { + const router = useRouter(); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const query = formData.get("q") as string; + if (query) { + router.push(`/search?q=${encodeURIComponent(query)}`); + } + }; + + return ( +
+ + + + ); +} diff --git a/thoughts-frontend/components/thought-list.tsx b/thoughts-frontend/components/thought-list.tsx new file mode 100644 index 0000000..d927ce1 --- /dev/null +++ b/thoughts-frontend/components/thought-list.tsx @@ -0,0 +1,47 @@ +import { Me, Thought } from "@/lib/api"; +import { ThoughtCard } from "./thought-card"; +import { Card, CardContent } from "./ui/card"; + +interface ThoughtListProps { + thoughts: Thought[]; + authorDetails: Map; + currentUser: Me | null; +} + +export function ThoughtList({ + thoughts, + authorDetails, + currentUser, +}: ThoughtListProps) { + if (thoughts.length === 0) { + return ( +

+ No thoughts to display. +

+ ); + } + + return ( + + +
+ {thoughts.map((thought) => { + const author = { + username: thought.authorUsername, + avatarUrl: null, + ...authorDetails.get(thought.authorUsername), + }; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/thoughts-frontend/components/ui/input.tsx b/thoughts-frontend/components/ui/input.tsx index 60c258e..705ba64 100644 --- a/thoughts-frontend/components/ui/input.tsx +++ b/thoughts-frontend/components/ui/input.tsx @@ -10,7 +10,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { className={cn( "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", - "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-fa-inner transition-shadow", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-fa-inner transition-shadow glass-effect glossy-effect bottom", className )} {...props} diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index 94d0b7c..f981347 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -60,6 +60,11 @@ export const UpdateProfileSchema = z.object({ topFriends: z.array(z.string()).max(8).optional(), }); +export const SearchResultsSchema = z.object({ + users: z.object({ users: z.array(UserSchema) }), + thoughts: z.object({ thoughts: z.array(ThoughtSchema) }), +}); + export type User = z.infer; export type Me = z.infer; export type Thought = z.infer; @@ -232,4 +237,14 @@ export const getFollowersList = (username: string, token: string | null) => {}, z.object({ users: z.array(UserSchema) }), token + ); + + + +export const search = (query: string, token: string | null) => + apiFetch( + `/search?q=${encodeURIComponent(query)}`, + {}, + SearchResultsSchema, + token ); \ No newline at end of file