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
|
* @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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ActionButton } from "../ui/ActionButton";
|
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 router = useRouter();
|
||||||
const [addUsername, setAddUsername] = useState("");
|
const [addUsername, setAddUsername] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<FriendUser[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [addError, setAddError] = useState("");
|
const [addError, setAddError] = useState("");
|
||||||
const [addSuccess, setAddSuccess] = 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
|
* @async
|
||||||
* @function handleSendRequest
|
* @function sendRequestToUser
|
||||||
* @param {React.FormEvent} e - The form submission event.
|
* @param {string} username - The target username to send a friend request to.
|
||||||
* @returns {Promise<void>} Resolves when the friend request process completes.
|
* @returns {Promise<void>} Resolves when the request completes.
|
||||||
*/
|
*/
|
||||||
const handleSendRequest = async (e: React.FormEvent) => {
|
const sendRequestToUser = async (username: string) => {
|
||||||
e.preventDefault();
|
|
||||||
setAddError("");
|
setAddError("");
|
||||||
setAddSuccess("");
|
setAddSuccess("");
|
||||||
if (!addUsername.trim()) return;
|
setLoadingUsername(username);
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/friends", {
|
const res = await fetch("/api/friends", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: addUsername }),
|
body: JSON.stringify({ username }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -48,16 +100,15 @@ export function AddFriendTab() {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setAddError(data.error || "Failed to send request");
|
setAddError(data.error || "Failed to send request");
|
||||||
} else {
|
} else {
|
||||||
setAddSuccess(
|
setAddSuccess(`Success! Friend request sent to ${username}.`);
|
||||||
`Success! Your friend request to ${addUsername} was sent.`,
|
|
||||||
);
|
|
||||||
setAddUsername("");
|
setAddUsername("");
|
||||||
|
setSearchResults([]);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setAddError("Internal server error");
|
setAddError("Internal server error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoadingUsername(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -67,32 +118,85 @@ export function AddFriendTab() {
|
||||||
Add Friend
|
Add Friend
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSendRequest} className="space-y-2">
|
<div className="relative">
|
||||||
<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">
|
{/* 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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={addUsername}
|
value={addUsername}
|
||||||
onChange={(e) => setAddUsername(e.target.value)}
|
onChange={(e) => setAddUsername(e.target.value)}
|
||||||
placeholder="You can add friends with their username"
|
placeholder="Type a 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"
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
{addError && (
|
{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>}
|
{addSuccess && <p className="text-xs text-accent mt-2">{addSuccess}</p>}
|
||||||
</form>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ export function FriendsHeader({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1"></span>
|
||||||
|
|
||||||
{/* Add Friend Tab */}
|
{/* Add Friend Tab */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -108,8 +110,8 @@ export function FriendsHeader({
|
||||||
onClick={() => setActiveTab("add")}
|
onClick={() => setActiveTab("add")}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<span className="inline md:hidden">Add Friend</span>
|
<span className="inline sm:hidden">Add</span>
|
||||||
<span className="hidden md:inline">Add</span>
|
<span className="hidden sm:inline">Add Friend</span>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export interface Friendship {
|
||||||
* @property {string} currentUserId - The unique identifier of the currently logged-in user.
|
* @property {string} currentUserId - The unique identifier of the currently logged-in user.
|
||||||
* @property {Friendship[]} initialFriendships - Initial list of friendships fetched from the server.
|
* @property {Friendship[]} initialFriendships - Initial list of friendships fetched from the server.
|
||||||
*/
|
*/
|
||||||
interface FriendsViewProps {
|
export interface FriendsViewProps {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
initialFriendships: Friendship[];
|
initialFriendships: Friendship[];
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +169,12 @@ export function FriendsView({
|
||||||
|
|
||||||
{/* Main Tab Content */}
|
{/* Main Tab Content */}
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
{activeTab === "add" && <AddFriendTab />}
|
{activeTab === "add" && (
|
||||||
|
<AddFriendTab
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
initialFriendships={friendships}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "pending" && (
|
{activeTab === "pending" && (
|
||||||
<PendingRequestsTab
|
<PendingRequestsTab
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue