feat: implement search functionality with results display, add search input component, and update API for search results

This commit is contained in:
2025-09-07 12:54:39 +02:00
parent 69eb225c1e
commit c9b8bd7b07
7 changed files with 171 additions and 2 deletions

View File

@@ -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 (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
<p className="text-muted-foreground">
Find users and thoughts across the platform.
</p>
</div>
);
}
const [results, me] = await Promise.all([
search(query, token).catch(() => null),
token ? getMe(token).catch(() => null) : null,
]);
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
if (results) {
results.users.users.forEach((user: User) => {
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
});
}
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground">
Showing results for: &quot;{query}&quot;
</p>
</header>
<main>
{results ? (
<Tabs defaultValue="thoughts" className="w-full">
<TabsList>
<TabsTrigger value="thoughts">
Thoughts ({results.thoughts.thoughts.length})
</TabsTrigger>
<TabsTrigger value="users">
Users ({results.users.users.length})
</TabsTrigger>
</TabsList>
<TabsContent value="thoughts">
<ThoughtList
thoughts={results.thoughts.thoughts}
authorDetails={authorDetails}
currentUser={me}
/>
</TabsContent>
<TabsContent value="users">
<UserListCard users={results.users.users} />
</TabsContent>
</Tabs>
) : (
<p className="text-center text-muted-foreground pt-8">
No results found or an error occurred.
</p>
)}
</main>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SearchInput } from "./search-input";
export function MainNav() { export function MainNav() {
const pathname = usePathname(); const pathname = usePathname();
@@ -17,6 +18,7 @@ export function MainNav() {
> >
Feed Feed
</Link> </Link>
<SearchInput />
</nav> </nav>
); );
} }

View File

@@ -46,7 +46,7 @@ export function PostThoughtForm() {
toast.success("Your thought has been posted!"); toast.success("Your thought has been posted!");
form.reset(); form.reset();
router.refresh(); // This is the key to updating the feed router.refresh(); // This is the key to updating the feed
} catch (err) { } catch {
toast.error("Failed to post thought. Please try again."); toast.error("Failed to post thought. Please try again.");
} }
} }

View File

@@ -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<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const query = formData.get("q") as string;
if (query) {
router.push(`/search?q=${encodeURIComponent(query)}`);
}
};
return (
<form onSubmit={handleSearch} className="relative w-full max-w-sm">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="q"
placeholder="Search for users or thoughts..."
className="pl-9 md:min-w-[250px]"
/>
</form>
);
}

View File

@@ -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<string, { avatarUrl?: string | null }>;
currentUser: Me | null;
}
export function ThoughtList({
thoughts,
authorDetails,
currentUser,
}: ThoughtListProps) {
if (thoughts.length === 0) {
return (
<p className="text-center text-muted-foreground pt-8">
No thoughts to display.
</p>
);
}
return (
<Card>
<CardContent className="divide-y p-0">
<div className="space-y-6 p-4">
{thoughts.map((thought) => {
const author = {
username: thought.authorUsername,
avatarUrl: null,
...authorDetails.get(thought.authorUsername),
};
return (
<ThoughtCard
key={thought.id}
thought={thought}
author={author}
currentUser={currentUser}
/>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -10,7 +10,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
className={cn( 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", "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]", "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 className
)} )}
{...props} {...props}

View File

@@ -60,6 +60,11 @@ export const UpdateProfileSchema = z.object({
topFriends: z.array(z.string()).max(8).optional(), 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<typeof UserSchema>; export type User = z.infer<typeof UserSchema>;
export type Me = z.infer<typeof MeSchema>; export type Me = z.infer<typeof MeSchema>;
export type Thought = z.infer<typeof ThoughtSchema>; export type Thought = z.infer<typeof ThoughtSchema>;
@@ -232,4 +237,14 @@ export const getFollowersList = (username: string, token: string | null) =>
{}, {},
z.object({ users: z.array(UserSchema) }), z.object({ users: z.array(UserSchema) }),
token token
);
export const search = (query: string, token: string | null) =>
apiFetch(
`/search?q=${encodeURIComponent(query)}`,
{},
SearchResultsSchema,
token
); );