feat(friends): add live user search and search API endpoint
This commit is contained in:
parent
28dc63e0fd
commit
a481f16a1d
4 changed files with 194 additions and 39 deletions
44
app/api/users/search/route.ts
Normal file
44
app/api/users/search/route.ts
Normal file
|
|
@ -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<NextResponse>} 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FriendUser[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [addError, setAddError] = useState("");
|
||||
const [addSuccess, setAddSuccess] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingUsername, setLoadingUsername] = useState<string | null>(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<void>} Resolves when the friend request process completes.
|
||||
* @function sendRequestToUser
|
||||
* @param {string} username - The target username to send a friend request to.
|
||||
* @returns {Promise<void>} 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
|
||||
</h3>
|
||||
<p className="text-xs text-muted mb-4">
|
||||
You can add friends with their username.
|
||||
You can add friends by searching for their username.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSendRequest} className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-0 relative items-stretch sm:items-center bg-surface rounded-lg p-2 sm:p-3 border border-muted/20 focus-within:border-accent transition-colors">
|
||||
<div className="relative">
|
||||
{/* Search field */}
|
||||
<div className="flex items-center bg-surface rounded-lg p-2 sm:p-3 border border-muted/20 focus-within:border-accent transition-colors">
|
||||
<input
|
||||
type="text"
|
||||
value={addUsername}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !addUsername.trim()}
|
||||
size="sm"
|
||||
>
|
||||
Send Friend Request
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{addError && (
|
||||
<p className="text-xs text-destructive mt-1">{addError}</p>
|
||||
<p className="text-xs text-destructive mt-2">{addError}</p>
|
||||
)}
|
||||
{addSuccess && <p className="text-xs text-accent mt-1">{addSuccess}</p>}
|
||||
</form>
|
||||
{addSuccess && <p className="text-xs text-accent mt-2">{addSuccess}</p>}
|
||||
|
||||
{/* Search Results */}
|
||||
{addUsername.trim().length >= 2 && (
|
||||
<div className="mt-2 bg-surface rounded-lg border border-muted/20 overflow-hidden shadow-lg">
|
||||
{isSearching ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Searching...
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
No users found matching "{addUsername}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-muted/10">
|
||||
{searchResults.map((user) => {
|
||||
if (user.id === currentUserId) return null; // Hide your own account
|
||||
|
||||
const friendshipStatus = getFriendshipStatus(user.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-2.5 hover:bg-background/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pr-2">
|
||||
<UserAvatar user={user} size="md" />
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{user.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status or Add Button */}
|
||||
{!friendshipStatus ? (
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loadingUsername === user.username}
|
||||
onClick={() => sendRequestToUser(user.username)}
|
||||
>
|
||||
{loadingUsername === user.username
|
||||
? "Sending..."
|
||||
: "Add Friend"}
|
||||
</ActionButton>
|
||||
) : (
|
||||
<span className="text-xs text-muted font-medium px-2 py-1 rounded bg-background/50 border border-muted/10">
|
||||
{friendshipStatus === "ACCEPTED"
|
||||
? "Friend"
|
||||
: "Pending"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ export function FriendsHeader({
|
|||
)}
|
||||
</button>
|
||||
|
||||
<span className="mx-1"></span>
|
||||
|
||||
{/* Add Friend Tab */}
|
||||
<ActionButton
|
||||
type="button"
|
||||
|
|
@ -108,8 +110,8 @@ export function FriendsHeader({
|
|||
onClick={() => setActiveTab("add")}
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline md:hidden">Add Friend</span>
|
||||
<span className="hidden md:inline">Add</span>
|
||||
<span className="inline sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add Friend</span>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
{activeTab === "add" && <AddFriendTab />}
|
||||
{activeTab === "add" && (
|
||||
<AddFriendTab
|
||||
currentUserId={currentUserId}
|
||||
initialFriendships={friendships}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "pending" && (
|
||||
<PendingRequestsTab
|
||||
|
|
|
|||
Loading…
Reference in a new issue