diff --git a/package-lock.json b/package-lock.json index 37a9dd0..66cd9b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -3439,6 +3440,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "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-use-callback-ref": { "version": "1.1.0", "license": "MIT", diff --git a/package.json b/package.json index 682a877..99b2516 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/src/app/[identifier]/page.tsx b/src/app/[identifier]/page.tsx index 1773cee..e20ce0b 100644 --- a/src/app/[identifier]/page.tsx +++ b/src/app/[identifier]/page.tsx @@ -2,6 +2,7 @@ import CastSearch from '@/components/CastSearch'; import NetworkResponse from '@/components/NetworkResponse'; import Search from '@/components/search'; +import SearchComponent from '@/components/SearchComponent'; interface ResponseProps { params: { identifier: string }; @@ -36,11 +37,11 @@ export default function Page({ params }: ResponseProps) { -
+
{isSearch ? ( - + ) : ( )} diff --git a/src/components/CastSearch.tsx b/src/components/CastSearch.tsx index 85ee9cd..8373991 100644 --- a/src/components/CastSearch.tsx +++ b/src/components/CastSearch.tsx @@ -1,16 +1,7 @@ -'use client'; -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Input } from '@/components/ui/input'; -import { NeynarCastCard, NeynarProfileCard } from '@neynar/react'; +// CastSearch.tsx +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { NeynarCastCard } from '@neynar/react'; import { Loader2 } from 'lucide-react'; -import debounce from 'lodash/debounce'; - -interface User { - fid: number; - username: string; - display_name: string; - pfp_url: string; -} interface Cast { hash: string; @@ -22,569 +13,137 @@ interface Cast { }; } -interface SearchParams { - query: string; - authorFid: string; +const CastSearch = ({ + searchQuery, + username, + channelId, +}: { + searchQuery: string; + username: string; channelId: string; -} - -interface Channel { - id: string; - name: string; - description: string; - image_url: string; -} - -const CastSearch = ({ initialQuery }: { initialQuery: string }) => { - const [username, setUsername] = useState(''); - const [channelId, setChannelId] = useState(''); - - const [searchParams, setSearchParams] = useState({ - query: initialQuery, - authorFid: '', - channelId: '', - }); +}) => { const [casts, setCasts] = useState([]); - const [searchUsers, setSearchUsers] = useState([]); - const [inputUsers, setInputUsers] = useState([]); - const [inputChannels, setInputChannels] = useState([]); - const [userCursor, setUserCursor] = useState(''); - const [castCursor, setCastCursor] = useState(''); - const [loading, setLoading] = useState({ - users: false, - casts: false, - inputUsers: false, - inputChannels: false, - }); - const [showUserDropdown, setShowUserDropdown] = useState(false); - - const usernameInputRef = useRef(null); - const dropdownRef = useRef(null); - const userObserverRef = useRef(null); - const castObserverRef = useRef(null); - const lastUserRef = useRef(null); - const lastCastRef = useRef(null); - const [showChannelDropdown, setShowChannelDropdown] = useState(false); - const channelDropdownRef = useRef(null); - - const handleShowMore = (identifier: string) => { - window.open(`/${identifier}`, '_blank', 'noopener,noreferrer'); - }; - - const fetchSearchUsers = useCallback(async (newSearch: boolean = false) => { - if (loading.users || (!newSearch && !userCursor)) return; - - setLoading((prev) => ({ ...prev, users: true })); - try { - const userUrl = new URL( - 'https://api.neynar.com/v2/farcaster/user/search' - ); - userUrl.searchParams.append('q', searchParams.query); - userUrl.searchParams.append('limit', '10'); - if (userCursor && !newSearch) - userUrl.searchParams.append('cursor', userCursor); - - const userResponse = await fetch(userUrl, { - headers: { - Accept: 'application/json', - api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', - }, - }); - - if (!userResponse.ok) - throw new Error('User search network response was not ok'); - const userData = await userResponse.json(); - setSearchUsers((prevUsers) => - newSearch - ? userData.result.users - : [...prevUsers, ...userData.result.users] - ); - if (userData.result.next) { - setUserCursor(userData.result.next.cursor); - } else { - setUserCursor(''); - } - } catch (error) { - console.error('Error fetching search users:', error); - } finally { - setLoading((prev) => ({ ...prev, users: false })); - } - }, []); - - const fetchCasts = useCallback( - async (newSearch: boolean = false) => { - if (loading.casts || (!newSearch && !castCursor)) return; - - setLoading((prev) => ({ ...prev, casts: true })); - try { - let castUrl = 'https://api.neynar.com/v2/farcaster/cast/search?'; - - // Append query parameters - castUrl += `q=${encodeURIComponent(initialQuery)}&limit=20`; - - if (castCursor && !newSearch) { - castUrl += `&cursor=${encodeURIComponent(castCursor)}`; - } - - if (searchParams.authorFid) { - castUrl += `&author_fid=${encodeURIComponent(searchParams.authorFid)}`; + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + const observer = useRef(null); + const lastCastElementRef = useCallback( + (node: HTMLDivElement) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + fetchCasts(); } - - if (searchParams.channelId) { - castUrl += `&channel_id=${encodeURIComponent(searchParams.channelId)}`; - } - - const castResponse = await fetch(castUrl, { - headers: { - Accept: 'application/json', - api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', - }, - }); - - if (!castResponse.ok) - throw new Error('Cast search network response was not ok'); - const castData = await castResponse.json(); - setCasts((prevCasts) => - newSearch - ? castData.result.casts - : [...prevCasts, ...castData.result.casts] - ); - if (castData.result.next) { - setCastCursor(castData.result.next.cursor); - } else { - setCastCursor(''); - } - } catch (error) { - console.error('Error fetching casts:', error); - } finally { - setLoading((prev) => ({ ...prev, casts: false })); - } + }); + if (node) observer.current.observe(node); }, - [searchParams] + [loading, hasMore] ); - const performSearch = useCallback(() => { - setSearchUsers([]); - setCasts([]); - setUserCursor(''); - setCastCursor(''); - fetchSearchUsers(true); - fetchCasts(true); - }, [fetchSearchUsers, fetchCasts]); + const fetchCasts = async () => { + if (loading) return; - // Debounced search function - const debouncedSearch = useCallback( - debounce(() => { - performSearch(); - }, 300), - [performSearch] - ); - - const fetchInputUsers = useCallback(async (inputUsername: string) => { - if (loading.inputUsers || inputUsername.length < 1) return; - - setLoading((prev) => ({ ...prev, inputUsers: true })); + setLoading(true); try { - const userUrl = new URL( - 'https://api.neynar.com/v2/farcaster/user/search' + const castUrl = new URL( + 'https://api.neynar.com/v2/farcaster/cast/search' ); - userUrl.searchParams.append('q', inputUsername); - userUrl.searchParams.append('limit', '5'); - - const userResponse = await fetch(userUrl, { - headers: { - Accept: 'application/json', - api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', - }, - }); + castUrl.searchParams.append('q', searchQuery); + castUrl.searchParams.append('limit', '20'); - if (!userResponse.ok) - throw new Error('User search network response was not ok'); - const userData = await userResponse.json(); - setInputUsers(userData.result.users); - } catch (error) { - console.error('Error fetching input users:', error); - } finally { - setLoading((prev) => ({ ...prev, inputUsers: false })); - } - }, []); + if (cursor) { + castUrl.searchParams.append('cursor', cursor); + } - const fetchInputChannels = useCallback(async (inputChannelId: string) => { - if (loading.inputChannels || inputChannelId.length < 1) return; + if (username) { + castUrl.searchParams.append('author_fid', username); + } - setLoading((prev) => ({ ...prev, inputChannels: true })); - try { - const channelUrl = new URL( - 'https://api.neynar.com/v2/farcaster/channel/search' - ); - channelUrl.searchParams.append('q', inputChannelId); - channelUrl.searchParams.append('limit', '5'); + if (channelId) { + castUrl.searchParams.append('channel_id', channelId); + } - const channelResponse = await fetch(channelUrl, { + const castResponse = await fetch(castUrl, { headers: { Accept: 'application/json', api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', }, }); - if (!channelResponse.ok) - throw new Error('Channel search network response was not ok'); - const channelData = await channelResponse.json(); - setInputChannels(channelData.channels); + if (!castResponse.ok) + throw new Error('Cast search network response was not ok'); + const castData = await castResponse.json(); + const newCasts = castData.result.casts; + + setCasts((prevCasts) => [...prevCasts, ...newCasts]); + setCursor(castData.result.next?.cursor || null); + setHasMore(!!castData.result.next?.cursor); } catch (error) { - console.error('Error fetching input channels:', error); + console.error('Error fetching casts:', error); } finally { - setLoading((prev) => ({ ...prev, inputChannels: false })); + setLoading(false); } - }, []); - - const handleUsernameChange = useCallback( - (e: React.ChangeEvent) => { - const newUsername = e.target.value; - setUsername(newUsername); - if (newUsername.length > 0) { - setShowUserDropdown(true); - } else { - setShowUserDropdown(false); - setSearchParams((prev) => ({ ...prev, authorFid: '' })); - } - fetchInputUsers(newUsername); - }, - [fetchInputUsers] - ); - - const handleUserSelect = useCallback((user: User) => { - setUsername(user.username); - setSearchParams((prev) => ({ ...prev, authorFid: user.fid.toString() })); - setShowUserDropdown(false); - }, []); - - const handleSearch = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - debouncedSearch(); - }, - [debouncedSearch] - ); - - // Set up Intersection Observers - useEffect(() => { - const userObserverOptions = { - root: null, - rootMargin: '0px', - threshold: 1.0, - }; - - const castObserverOptions = { - root: null, - rootMargin: '0px', - threshold: 1.0, - }; - - userObserverRef.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - fetchSearchUsers(false); - } - }, userObserverOptions); - - castObserverRef.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - fetchCasts(false); - } - }, castObserverOptions); - - return () => { - if (userObserverRef.current) userObserverRef.current.disconnect(); - if (castObserverRef.current) castObserverRef.current.disconnect(); - }; - }, [fetchSearchUsers, fetchCasts]); - - // Observe last user and cast elements - useEffect(() => { - if (lastUserRef.current && userObserverRef.current) { - userObserverRef.current.observe(lastUserRef.current); - } - if (lastCastRef.current && castObserverRef.current) { - castObserverRef.current.observe(lastCastRef.current); - } - }, [searchUsers, casts]); - - // Trigger search when searchParams change - useEffect(() => { - debouncedSearch(); - }, [searchParams, debouncedSearch]); - - // Handle click outside of dropdown - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setShowUserDropdown(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleUsernameKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (inputUsers.length > 0) { - handleUserSelect(inputUsers[0]); - } else { - handleSearch(e as any); - } - } - }, - [inputUsers, handleUserSelect, handleSearch] - ); - - const handleChannelIdKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSearch(e as any); - } - }, - [handleSearch] - ); - - // ... (rest of the existing functions) - - const handleChannelIdChange = useCallback( - (e: React.ChangeEvent) => { - const newChannelId = e.target.value; - setChannelId(newChannelId); - if (newChannelId.length > 0) { - setShowChannelDropdown(true); - } else { - setShowChannelDropdown(false); - setSearchParams((prev) => ({ ...prev, channelId: '' })); - } - fetchInputChannels(newChannelId); - }, - [fetchInputChannels] - ); - - const handleChannelSelect = useCallback((channel: Channel) => { - setChannelId(channel.name); - setSearchParams((prev) => ({ ...prev, channelId: channel.id })); - setShowChannelDropdown(false); - }, []); - - // ... (rest of the existing useEffects) + }; - // Add a new useEffect for handling clicks outside the channel dropdown useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - channelDropdownRef.current && - !channelDropdownRef.current.contains(event.target as Node) - ) { - setShowChannelDropdown(false); - } - }; + setCasts([]); + setCursor(null); + setHasMore(true); + fetchCasts(); + }, [searchQuery, username, channelId]); - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + const handleShowMore = (identifier: string) => { + window.open(`/${identifier}`, '_blank', 'noopener,noreferrer'); + }; return ( -
-
-
-
- { - if (username && username.length > 0) { - setShowUserDropdown(true); - } - }} - /> - {showUserDropdown && ( -
-
    - {inputUsers.map((user) => ( -
  • handleUserSelect(user)} - > - {user.display_name} - - {user.display_name} (@{user.username}) - -
  • - ))} -
-
- )} -
-
- - {showChannelDropdown && ( -
-
    - {inputChannels.map((channel) => ( -
  • handleChannelSelect(channel)} - > - {channel.name} - {channel.name} -
  • - ))} -
-
- )} +
+ {casts.map((cast, index) => ( +
+ +
+
- - - {loading.users && - loading.casts && - searchUsers.length === 0 && - casts.length === 0 ? ( -
- - Loading... -
- ) : ( -
- {searchUsers.length > 0 && ( -
-

Users

-
- {searchUsers.map((user, index) => ( -
- - -
- ))} - {loading.users && ( -
- - - Loading more users - -
- )} -
-
- )} - - {casts.length > 0 && ( -
-

Casts

-
- {casts.map((cast, index) => ( -
- - -
- ))} - {loading.casts && ( -
- - - Loading more casts... - -
- )} -
-
- )} + ))} + {loading && ( +
+ + + Loading more casts... +
)} - {searchUsers.length === 0 && - casts.length === 0 && - !loading.users && - !loading.casts && ( -

- No results found -

- )} + {!loading && casts.length === 0 && ( +

No casts found

+ )}
); }; diff --git a/src/components/SearchComponent.tsx b/src/components/SearchComponent.tsx new file mode 100644 index 0000000..0208aa2 --- /dev/null +++ b/src/components/SearchComponent.tsx @@ -0,0 +1,292 @@ +// Search.tsx +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { Input } from '@/components/ui/input'; +import UserSearch from './UserSearch'; +import CastSearch from './CastSearch'; +import { debounce } from 'lodash'; + +interface User { + fid: number; + username: string; + display_name: string; + pfp_url: string; +} + +interface Channel { + id: string; + name: string; + description: string; + image_url: string; +} + +const SearchComponent = ({ initialQuery }: { initialQuery: string }) => { + const [activeTab, setActiveTab] = useState('casts'); + const [searchQuery, setSearchQuery] = useState(initialQuery); + const [username, setUsername] = useState(''); + const [channelId, setChannelId] = useState(''); + const [submittedUsername, setSubmittedUsername] = useState(''); + const [submittedChannelId, setSubmittedChannelId] = useState(''); + const [inputUsers, setInputUsers] = useState([]); + const [inputChannels, setInputChannels] = useState([]); + const [showUserDropdown, setShowUserDropdown] = useState(false); + const [showChannelDropdown, setShowChannelDropdown] = useState(false); + const [loading, setLoading] = useState({ + inputUsers: false, + inputChannels: false, + }); + + const userDropdownRef = useRef(null); + const channelDropdownRef = useRef(null); + + const handleTabChange = (value: string) => { + setActiveTab(value); + setUsername(''); + setChannelId(''); + setSubmittedUsername(''); + setSubmittedChannelId(''); + }; + + const fetchInputUsers = useCallback(async (inputUsername: string) => { + if (loading.inputUsers || inputUsername.length < 1) return; + + setLoading((prev) => ({ ...prev, inputUsers: true })); + try { + const userUrl = new URL( + 'https://api.neynar.com/v2/farcaster/user/search' + ); + userUrl.searchParams.append('q', inputUsername); + userUrl.searchParams.append('limit', '5'); + + const userResponse = await fetch(userUrl, { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + }); + + if (!userResponse.ok) + throw new Error('User search network response was not ok'); + const userData = await userResponse.json(); + setInputUsers(userData.result.users); + } catch (error) { + console.error('Error fetching input users:', error); + } finally { + setLoading((prev) => ({ ...prev, inputUsers: false })); + } + }, []); + + const fetchInputChannels = useCallback(async (inputChannelId: string) => { + if (loading.inputChannels || inputChannelId.length < 1) return; + + setLoading((prev) => ({ ...prev, inputChannels: true })); + try { + const channelUrl = new URL( + 'https://api.neynar.com/v2/farcaster/channel/search' + ); + channelUrl.searchParams.append('q', inputChannelId); + channelUrl.searchParams.append('limit', '5'); + + const channelResponse = await fetch(channelUrl, { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + }); + + if (!channelResponse.ok) + throw new Error('Channel search network response was not ok'); + const channelData = await channelResponse.json(); + setInputChannels(channelData.channels); + } catch (error) { + console.error('Error fetching input channels:', error); + } finally { + setLoading((prev) => ({ ...prev, inputChannels: false })); + } + }, []); + + const debouncedFetchInputUsers = useCallback(debounce(fetchInputUsers, 300), [ + fetchInputUsers, + ]); + const debouncedFetchInputChannels = useCallback( + debounce(fetchInputChannels, 300), + [fetchInputChannels] + ); + + const handleUsernameChange = (e: React.ChangeEvent) => { + const newUsername = e.target.value; + setUsername(newUsername); + if (newUsername.length > 0) { + setShowUserDropdown(true); + debouncedFetchInputUsers(newUsername); + } else { + setShowUserDropdown(false); + setSubmittedUsername(''); + } + }; + + const handleChannelIdChange = (e: React.ChangeEvent) => { + const newChannelId = e.target.value; + setChannelId(newChannelId); + if (newChannelId.length > 0) { + setShowChannelDropdown(true); + debouncedFetchInputChannels(newChannelId); + } else { + setShowChannelDropdown(false); + setSubmittedChannelId(''); + } + }; + + const handleUserSelect = (user: User) => { + setUsername(user.username); + setSubmittedUsername(user.fid.toString()); + setShowUserDropdown(false); + }; + + const handleChannelSelect = (channel: Channel) => { + setChannelId(channel.name); + setSubmittedChannelId(channel.id); + setShowChannelDropdown(false); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + userDropdownRef.current && + !userDropdownRef.current.contains(event.target as Node) + ) { + setShowUserDropdown(false); + } + if ( + channelDropdownRef.current && + !channelDropdownRef.current.contains(event.target as Node) + ) { + setShowChannelDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
+ + +
+ +
+ {activeTab === 'casts' && ( + <> +
+ { + if (e.key === 'Enter') { + setSubmittedUsername(username); + setShowUserDropdown(false); + } + }} + placeholder="Username (optional)" + className="w-full rounded-none bg-white text-black" + /> + {showUserDropdown && ( +
+
    + {inputUsers.map((user) => ( +
  • handleUserSelect(user)} + > + {user.display_name} + + {user.display_name} (@{user.username}) + +
  • + ))} +
+
+ )} +
+
+ { + if (e.key === 'Enter') { + setSubmittedChannelId(channelId); + setShowChannelDropdown(false); + } + }} + placeholder="Channel ID (optional)" + className="w-full rounded-none bg-white text-black" + /> + {showChannelDropdown && ( +
+
    + {inputChannels.map((channel) => ( +
  • handleChannelSelect(channel)} + > + {channel.name} + {channel.name} +
  • + ))} +
+
+ )} +
+ + )} +
+ + {activeTab === 'casts' ? ( + + ) : ( + + )} +
+ ); +}; + +export default SearchComponent; diff --git a/src/components/UserSearch.tsx b/src/components/UserSearch.tsx new file mode 100644 index 0000000..d11b704 --- /dev/null +++ b/src/components/UserSearch.tsx @@ -0,0 +1,132 @@ +// UserSearch.tsx +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { NeynarProfileCard } from '@neynar/react'; +import { Loader2 } from 'lucide-react'; + +interface User { + fid: number; + username: string; + display_name: string; + pfp_url: string; +} + +const UserSearch = ({ searchQuery }: { searchQuery: string }) => { + const [users, setUsers] = useState([]); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + const observer = useRef(null); + const lastUserElementRef = useCallback( + (node: HTMLDivElement) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + fetchUsers(); + } + }); + if (node) observer.current.observe(node); + }, + [loading, hasMore] + ); + + const fetchUsers = async () => { + if (loading || !hasMore) return; + + setLoading(true); + try { + const userUrl = new URL( + 'https://api.neynar.com/v2/farcaster/user/search' + ); + userUrl.searchParams.append('q', searchQuery); + userUrl.searchParams.append('limit', '10'); + if (cursor) { + userUrl.searchParams.append('cursor', cursor); + } + + const userResponse = await fetch(userUrl, { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + }); + + if (!userResponse.ok) + throw new Error('User search network response was not ok'); + const userData = await userResponse.json(); + const newUsers = userData.result.users; + const newCursor = userData.result.next?.cursor || null; + + setUsers((prevUsers) => [...prevUsers, ...newUsers]); + setCursor(newCursor); + setHasMore(!!newCursor); + } catch (error) { + console.error('Error fetching search users:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + setUsers([]); + setCursor(null); + setHasMore(true); + fetchUsers(); + }, [searchQuery]); + + const handleShowMore = (identifier: string) => { + window.open(`/${identifier}`, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+ {users.map((user, index) => ( +
+ +
+ +
+
+ ))} + {loading && ( +
+ + + Loading more users... + +
+ )} + {!hasMore && users.length > 0 && ( +

+ No more users to load +

+ )} + {!loading && users.length === 0 && ( +

No users found

+ )} +
+ ); +}; + +export default UserSearch; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..7315537 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +'use client'; + +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; + +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent };