From a481f16a1dcd17f2b5c82c5f8e1b6a82af69cd29 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Fri, 4 Sep 2026 04:57:27 +0200 Subject: [PATCH] feat(friends): add live user search and search API endpoint --- app/api/users/search/route.ts | 44 +++++++ components/friends/AddFriendTab.tsx | 174 +++++++++++++++++++++------ components/friends/FriendsHeader.tsx | 6 +- components/friends/FriendsView.tsx | 9 +- 4 files changed, 194 insertions(+), 39 deletions(-) create mode 100644 app/api/users/search/route.ts diff --git a/app/api/users/search/route.ts b/app/api/users/search/route.ts new file mode 100644 index 0000000..bf05716 --- /dev/null +++ b/app/api/users/search/route.ts @@ -0,0 +1,44 @@ +/** + * @file app/api/users/search/route.ts + * @description API route handler for searching users by username with query validation and database lookup. + */ + +import { NextResponse } from "next/server"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { ilike } from "drizzle-orm"; + +/** + * Handles GET requests to search for users based on a query string parameter. + * + * @async + * @function GET + * @param {Request} request - The incoming HTTP request object containing URL search parameters. + * @returns {Promise} A JSON response containing the array of matching users or an error message. + */ +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get("q")?.trim(); + + if (!query || query.length < 2) { + return NextResponse.json([]); + } + + try { + const matchingUsers = await db + .select({ + id: users.id, + username: users.username, + color: users.color, + status: users.status, + }) + .from(users) + .where(ilike(users.username, `%${query}%`)) + .limit(8); + + return NextResponse.json(matchingUsers); + } catch (error) { + console.error("Search API error:", error); + return NextResponse.json({ error: "Search failed" }, { status: 500 }); + } +} diff --git a/components/friends/AddFriendTab.tsx b/components/friends/AddFriendTab.tsx index 0d15536..9067c84 100644 --- a/components/friends/AddFriendTab.tsx +++ b/components/friends/AddFriendTab.tsx @@ -1,46 +1,98 @@ /** * @file components/friends/AddFriendTab.tsx - * @description Tab component providing a form to send friend requests via username. + * @description Tab component allowing users to search for others by username, view live results, and send or track friend requests. */ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { ActionButton } from "../ui/ActionButton"; +import { UserAvatar } from "../ui/UserAvatar"; +import { FriendsViewProps, FriendUser } from "./FriendsView"; /** - * Renders a form to add new friends by entering their username. + * Renders the add friend tab containing the live search input, results list, and request management. * - * @returns {JSX.Element} The rendered add friend tab component. + * @param {FriendsViewProps} props - The component props. + * @param {string} props.currentUserId - The unique identifier of the currently logged-in user. + * @param {Friendship[]} props.initialFriendships - Array of existing friendships. + * @returns {JSX.Element} The rendered add friend tab container. */ -export function AddFriendTab() { +export function AddFriendTab({ + currentUserId, + initialFriendships, +}: FriendsViewProps) { const router = useRouter(); const [addUsername, setAddUsername] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); const [addError, setAddError] = useState(""); const [addSuccess, setAddSuccess] = useState(""); - const [loading, setLoading] = useState(false); + const [loadingUsername, setLoadingUsername] = useState(null); + + // Live search as you type, with debounce + useEffect(() => { + const query = addUsername.trim(); + if (query.length < 2) { + setSearchResults([]); + return; + } + + const timer = setTimeout(async () => { + setIsSearching(true); + try { + const res = await fetch( + `/api/users/search?q=${encodeURIComponent(query)}`, + ); + if (res.ok) { + const data = await res.json(); + setSearchResults(data); + } + } catch (err) { + console.error("Search error:", err); + } finally { + setIsSearching(false); + } + }, 250); + + return () => clearTimeout(timer); + }, [addUsername]); /** - * Handles the form submission to send a friend request asynchronously. + * Checks the friendship status for a specific user ID against the current user. + * + * @function getFriendshipStatus + * @param {string} userId - The unique identifier of the user to check status for. + * @returns {string | null} The friendship status string ("PENDING", "ACCEPTED", "BLOCKED") or null if none exists. + */ + const getFriendshipStatus = (userId: string) => { + const friendship = initialFriendships.find( + (f) => + (f.senderId === userId && f.receiverId === currentUserId) || + (f.receiverId === userId && f.senderId === currentUserId), + ); + return friendship ? friendship.status : null; + }; + + /** + * Sends a friend request to a specified username asynchronously. * * @async - * @function handleSendRequest - * @param {React.FormEvent} e - The form submission event. - * @returns {Promise} Resolves when the friend request process completes. + * @function sendRequestToUser + * @param {string} username - The target username to send a friend request to. + * @returns {Promise} Resolves when the request completes. */ - const handleSendRequest = async (e: React.FormEvent) => { - e.preventDefault(); + const sendRequestToUser = async (username: string) => { setAddError(""); setAddSuccess(""); - if (!addUsername.trim()) return; + setLoadingUsername(username); - setLoading(true); try { const res = await fetch("/api/friends", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: addUsername }), + body: JSON.stringify({ username }), }); const data = await res.json(); @@ -48,16 +100,15 @@ export function AddFriendTab() { if (!res.ok) { setAddError(data.error || "Failed to send request"); } else { - setAddSuccess( - `Success! Your friend request to ${addUsername} was sent.`, - ); + setAddSuccess(`Success! Friend request sent to ${username}.`); setAddUsername(""); + setSearchResults([]); router.refresh(); } } catch { setAddError("Internal server error"); } finally { - setLoading(false); + setLoadingUsername(null); } }; @@ -67,32 +118,85 @@ export function AddFriendTab() { Add Friend

- You can add friends with their username. + You can add friends by searching for their username.

-
-
+
+ {/* Search field */} +
setAddUsername(e.target.value)} - placeholder="You can add friends with their username" - className="w-full bg-transparent text-sm text-foreground placeholder:text-muted/60 focus:outline-none py-1 sm:py-0 sm:pr-36" + placeholder="Type a username..." + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted/60 focus:outline-none" /> - - Send Friend Request -
+ + {/* Errors */} {addError && ( -

{addError}

+

{addError}

)} - {addSuccess &&

{addSuccess}

} - + {addSuccess &&

{addSuccess}

} + + {/* Search Results */} + {addUsername.trim().length >= 2 && ( +
+ {isSearching ? ( +
+ Searching... +
+ ) : searchResults.length === 0 ? ( +
+ No users found matching "{addUsername}" +
+ ) : ( +
+ {searchResults.map((user) => { + if (user.id === currentUserId) return null; // Hide your own account + + const friendshipStatus = getFriendshipStatus(user.id); + + return ( +
+
+ + + {user.username} + +
+ + {/* Status or Add Button */} + {!friendshipStatus ? ( + sendRequestToUser(user.username)} + > + {loadingUsername === user.username + ? "Sending..." + : "Add Friend"} + + ) : ( + + {friendshipStatus === "ACCEPTED" + ? "Friend" + : "Pending"} + + )} +
+ ); + })} +
+ )} +
+ )} +
); } diff --git a/components/friends/FriendsHeader.tsx b/components/friends/FriendsHeader.tsx index 246ff8e..592cee5 100644 --- a/components/friends/FriendsHeader.tsx +++ b/components/friends/FriendsHeader.tsx @@ -101,6 +101,8 @@ export function FriendsHeader({ )} + + {/* Add Friend Tab */} setActiveTab("add")} size="sm" > - Add Friend - Add + Add + Add Friend diff --git a/components/friends/FriendsView.tsx b/components/friends/FriendsView.tsx index a70f1c4..1888f01 100644 --- a/components/friends/FriendsView.tsx +++ b/components/friends/FriendsView.tsx @@ -64,7 +64,7 @@ export interface Friendship { * @property {string} currentUserId - The unique identifier of the currently logged-in user. * @property {Friendship[]} initialFriendships - Initial list of friendships fetched from the server. */ -interface FriendsViewProps { +export interface FriendsViewProps { currentUserId: string; initialFriendships: Friendship[]; } @@ -169,7 +169,12 @@ export function FriendsView({ {/* Main Tab Content */}
- {activeTab === "add" && } + {activeTab === "add" && ( + + )} {activeTab === "pending" && (