diff --git a/package-lock.json b/package-lock.json index fa3b317..d16a30a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "axios": "^1.6.8", @@ -3218,6 +3219,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", + "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "license": "MIT", diff --git a/package.json b/package.json index 8eca8bd..a0cb072 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "axios": "^1.6.8", diff --git a/src/components/CastSearch.tsx b/src/components/CastSearch.tsx index 8ed52d4..280b808 100644 --- a/src/components/CastSearch.tsx +++ b/src/components/CastSearch.tsx @@ -1,20 +1,38 @@ 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { NeynarCastCard } from '@neynar/react'; import { useRouter } from 'next/navigation'; import useSearchParamsWithoutSuspense from '@/hooks/useSearchParamsWithoutSuspense'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +interface User { + fid: number; + username: string; + display_name: string; + pfp_url: string; +} const CastSearch = ({ query }: { query: string }) => { const params = useSearchParamsWithoutSuspense(); const router = useRouter(); + const [username, setUsername] = useState(''); const [authorFid, setAuthorFid] = useState(''); const [channelId, setChannelId] = useState(''); const [casts, setCasts] = useState([]); const [cursor, setCursor] = useState(''); const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const [showUserPopover, setShowUserPopover] = useState(false); + + const usernameInputRef = useRef(null); + const popoverRef = useRef(null); // Initialize filters from URL params useEffect(() => { @@ -24,6 +42,52 @@ const CastSearch = ({ query }: { query: string }) => { } }, [params]); + const fetchUsers = useCallback(async (username: string) => { + if (username.length < 1) { + setUsers([]); + return; + } + + try { + const response = await fetch( + `https://api.neynar.com/v2/farcaster/user/search?q=${username}`, + { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + } + ); + + if (!response.ok) throw new Error('Network response was not ok'); + + const data = await response.json(); + setUsers(data.result.users); + setShowUserPopover(true); + } catch (error) { + console.error('Error fetching users:', error); + } + }, []); + + const handleUsernameChange = useCallback( + (e: React.ChangeEvent) => { + const newUsername = e.target.value; + setUsername(newUsername); + fetchUsers(newUsername); + }, + [fetchUsers] + ); + + const handleUserSelect = useCallback((user: User) => { + setAuthorFid(user.fid.toString()); + setUsername(user.username); + setShowUserPopover(false); + const newParams = new URLSearchParams(); + if (channelId) newParams.append('channelId', channelId); + newParams.append('authorFid', user.fid.toString()); + router.push(`/${query}?${newParams.toString()}`); + }, []); + const fetchCasts = useCallback( async (newSearch: boolean = false) => { setLoading(true); @@ -79,16 +143,20 @@ const CastSearch = ({ query }: { query: string }) => { return () => window.removeEventListener('scroll', handleScroll); }, [handleScroll]); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - const newParams = new URLSearchParams(); - if (authorFid) newParams.append('authorFid', authorFid); - if (channelId) newParams.append('channelId', channelId); - router.push(`/${query}?${newParams.toString()}`); - setCasts([]); - setCursor(''); - fetchCasts(true); - }; + const handleSearch = useCallback( + (e: any) => { + e.preventDefault(); + const newParams = new URLSearchParams(); + + if (authorFid) newParams.append('authorFid', authorFid); + if (channelId) newParams.append('channelId', channelId); + router.push(`/${query}?${newParams.toString()}`); + setCasts([]); + setCursor(''); + fetchCasts(true); + }, + [authorFid, channelId, query, router, fetchCasts] + ); // Initial fetch when component mounts useEffect(() => { @@ -97,17 +165,61 @@ const CastSearch = ({ query }: { query: string }) => { } }, []); // Empty dependency array ensures this only runs once on mount + // Handle click outside of popover + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) + ) { + setShowUserPopover(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return (
- setAuthorFid(e.target.value)} - placeholder="Author FID (optional)" - className="w-full" - /> +
+ setShowUserPopover(true)} + /> + {showUserPopover && ( +
+
    + {users.map((user) => ( +
  • handleUserSelect(user)} + > + {user.display_name} + {user.display_name} (@{user.username}) +
  • + ))} +
+
+ )} +
, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent };