feat: implement search functionality with results display, add search input component, and update API for search results
This commit is contained in:
76
thoughts-frontend/app/search/page.tsx
Normal file
76
thoughts-frontend/app/search/page.tsx
Normal 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: "{query}"
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
29
thoughts-frontend/components/search-input.tsx
Normal file
29
thoughts-frontend/components/search-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
47
thoughts-frontend/components/thought-list.tsx
Normal file
47
thoughts-frontend/components/thought-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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}
|
||||||
|
@@ -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
|
||||||
);
|
);
|
Reference in New Issue
Block a user