diff --git a/apps/blog/components/popover-card-data.tsx b/apps/blog/components/popover-card-data.tsx index f5fbc475e2ea756bbf6b9ce2792332a9b55562a7..3f5b90ed1191b4df90326063b7128e1d70198abb 100644 --- a/apps/blog/components/popover-card-data.tsx +++ b/apps/blog/components/popover-card-data.tsx @@ -49,7 +49,7 @@ export function PopoverCardData({ author, blacklist }: { author: string; blackli const legalBlockedUser = userIllegalContent.some((e) => e === account?.name); return ( -
+
{account && !isLoading && follows.data && !follows.isLoading ? ( <>
diff --git a/apps/blog/components/site-header.tsx b/apps/blog/components/site-header.tsx index 8685c168bc94fc098ed78466cc5e71797952ac96..87239716a5882956cf2e98b08689a9fd3713f502 100644 --- a/apps/blog/components/site-header.tsx +++ b/apps/blog/components/site-header.tsx @@ -20,13 +20,12 @@ import LangToggle from './lang-toggle'; import { PieChart, Pie } from 'recharts'; import useManabars from './hooks/useManabars'; import { hoursAndMinutes } from '../lib/utils'; - import { getAccount } from '@transaction/lib/hive'; import TooltipContainer from '@ui/components/tooltip-container'; -import { ModeSwitchInput } from '@ui/components/mode-switch-input'; import { useRouter } from 'next/router'; import { cn } from '@ui/lib/utils'; import { getHiveSenseStatus } from '../lib/get-data'; +import SearchBar from '../feature/search/search-bar'; const SiteHeader: FC = () => { const { t } = useTranslation('common_blog'); @@ -90,7 +89,7 @@ const SiteHeader: FC = () => {
-
+
{siteConfig.name} {siteConfig.chainEnv !== 'mainnet' && ( {siteConfig.chainEnv} @@ -123,7 +122,7 @@ const SiteHeader: FC = () => { {router.pathname === '/search' ? ( ) : ( - + )}
diff --git a/apps/blog/feature/search/autocompleter.tsx b/apps/blog/feature/search/autocompleter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e1b2ee98f4689018f4800553a15992401aadb81 --- /dev/null +++ b/apps/blog/feature/search/autocompleter.tsx @@ -0,0 +1,148 @@ +import { Command as CommandPrimitive } from 'cmdk'; +import { useState, useCallback, type KeyboardEvent, RefObject } from 'react'; +import { Check } from 'lucide-react'; +import { cn } from '@ui/lib/utils'; +import { Skeleton } from '@ui/components/skeleton'; +import { CommandInput } from './command'; +import { CommandGroup, CommandItem, CommandList } from '@ui/components/command'; + +type AutoCompleteProps = { + onKeyDown: (event: KeyboardEvent) => void; + options: string[]; + emptyMessage: string; + value: string; + onValueChange: (value: string) => void; + isLoading?: boolean; + disabled?: boolean; + className?: string; + inputRef: RefObject; + placeholder?: string; +}; + +export const AutoComplete = ({ + onKeyDown, + options, + placeholder, + emptyMessage, + value, + onValueChange, + disabled, + className, + inputRef, + isLoading = false +}: AutoCompleteProps) => { + const [isOpen, setOpen] = useState(false); + const [selected, setSelected] = useState(value); + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const input = inputRef.current; + if (!input) { + return; + } + + // Keep the options displayed when the user is typing + if (!isOpen) { + setOpen(true); + } + + // This is not a default behaviour of the field + if (event.key === 'Enter' && input.value !== '') { + const optionToSelect = options.find((option) => option === input.value); + if (optionToSelect) { + setSelected(optionToSelect); + onValueChange(optionToSelect ?? ''); + } + } + + if (event.key === 'Escape') { + input.blur(); + } + }, + [isOpen, options, onValueChange] + ); + + // const handleBlur = useCallback(() => { + // setOpen(false); + // onValueChange(selected); + // }, [selected]); + + const handleSelectOption = useCallback( + (selectedOption: string) => { + onValueChange(selectedOption); + + setSelected(selectedOption); + onValueChange(selectedOption); + + // This is a hack to prevent the input from being focused after the user selects an option + // We can call this hack: "The next tick" + setTimeout(() => { + inputRef?.current?.blur(); + }, 0); + }, + [onValueChange] + ); + + return ( + +
+ setOpen(true)} + placeholder={placeholder} + disabled={disabled} + className="text-base" + containterClassName={className} + /> +
+
+
+ + {isLoading ? ( + +
+ +
+
+ ) : null} + {options.length > 0 && !isLoading ? ( + + {options.map((option) => { + const isSelected = selected === option; + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + onSelect={() => handleSelectOption(option)} + className="flex w-full items-center gap-2" + > + {option} + {isSelected ? : null} + + ); + })} + + ) : null} + {!isLoading ? ( + + {emptyMessage} + + ) : null} +
+
+
+
+ ); +}; diff --git a/apps/blog/feature/search/command.tsx b/apps/blog/feature/search/command.tsx new file mode 100644 index 0000000000000000000000000000000000000000..db15187eafb9d5eb410e3f5fd84246de4fd68489 --- /dev/null +++ b/apps/blog/feature/search/command.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Command as CommandPrimitive } from 'cmdk'; +import { cn } from '@ui/lib/utils'; + +interface CommandInputProps extends React.ComponentProps { + containterClassName?: string; +} +function CommandInput({ className, containterClassName, ...props }: CommandInputProps) { + return ( +
+ +
+ ); +} + +export { CommandInput }; diff --git a/apps/blog/feature/search/search-bar.tsx b/apps/blog/feature/search/search-bar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..389d7f4b336161567f3af437e1191c6cbce3c933 --- /dev/null +++ b/apps/blog/feature/search/search-bar.tsx @@ -0,0 +1,93 @@ +import clsx from 'clsx'; +import { useEffect, useRef, KeyboardEvent } from 'react'; +import { getPlaceholder } from './utils/lib'; +import { useSearch } from '@ui/hooks/useSearch'; +import { AutoComplete } from './autocompleter'; +import SmartSelect from './smart-select'; + +const SearchBar = ({ aiAvailable }: { aiAvailable: boolean }) => { + const { inputValue, setInputValue, mode, setMode, handleSearch, secondInputValue, setSecondInputValue } = + useSearch(aiAvailable); + const inputRef = useRef(null); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + handleSearch(inputValue, mode); + } + }; + const placeholder = getPlaceholder(mode); + + useEffect(() => { + if (inputValue.startsWith('/')) { + setMode('userTopics'); + setInputValue(inputValue.slice(1)); + } + if (inputValue.startsWith('%')) { + setMode('ai'); + setInputValue(inputValue.slice(1)); + } + if (inputValue.startsWith('$')) { + setMode('classic'); + setInputValue(inputValue.slice(1)); + } + if (inputValue.startsWith('@')) { + setMode('users'); + setInputValue(inputValue.slice(1)); + } + if (inputValue.startsWith('#')) { + setMode('tags'); + setInputValue(inputValue.slice(1)); + } + if (inputValue.startsWith('!')) { + setMode('community'); + setInputValue(inputValue.slice(1)); + } + }, [inputValue]); + return ( +
+ + + {mode === 'userTopics' ? ( + <> + + + + + + ) : null} +
+ ); +}; + +export default SearchBar; diff --git a/apps/blog/feature/search/select.tsx b/apps/blog/feature/search/select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ea00fb7b0fd2d5101cac3b2ae31be74641b13c2 --- /dev/null +++ b/apps/blog/feature/search/select.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; +import { cn } from '@ui/lib/utils'; + +function SelectTrigger({ + className, + size = 'sm', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + {children} + + ); +} + +function SelectContent({ + className, + children, + position = 'popper', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + {children} + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { SelectContent, SelectItem, SelectTrigger }; diff --git a/apps/blog/feature/search/smart-select.tsx b/apps/blog/feature/search/smart-select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..19abd422944a3daa7342fa490183da8423b66342 --- /dev/null +++ b/apps/blog/feature/search/smart-select.tsx @@ -0,0 +1,57 @@ +import { Bot, Search, AtSign, Hash, PersonStanding } from 'lucide-react'; +import { SearchMode } from '@ui/hooks/useSearch'; +import { SelectContent, SelectItem, SelectTrigger } from './select'; +import { Select, SelectGroup, SelectValue } from '@ui/components'; + +const SmartSelect = ({ + value, + onValueChange, + aiDisabled +}: { + value: SearchMode; + onValueChange: (value: SearchMode) => void; + aiDisabled?: boolean; +}) => { + return ( + + ); +}; +export default SmartSelect; diff --git a/apps/blog/feature/search/utils/lib.ts b/apps/blog/feature/search/utils/lib.ts new file mode 100644 index 0000000000000000000000000000000000000000..b708e085efc2a023f733862bb90c53eea766c948 --- /dev/null +++ b/apps/blog/feature/search/utils/lib.ts @@ -0,0 +1,20 @@ +import { SearchMode } from '@ui/hooks/useSearch'; + +export const getPlaceholder = (value: SearchMode) => { + switch (value) { + case 'ai': + return 'AI Search...'; + case 'classic': + return 'Search...'; + case 'users': + return 'Search users...'; + case 'userTopics': + return 'Username...'; + case 'tags': + return 'Search tags...'; + case 'community': + return 'Search community...'; + default: + return 'Search something...'; + } +}; diff --git a/apps/blog/lib/get-data.tsx b/apps/blog/lib/get-data.tsx index 87f994861d977c15fee65af3915f4c95cc2300a4..4b378fa9eeb2622349e69f6bca319bd3ac34f2e2 100644 --- a/apps/blog/lib/get-data.tsx +++ b/apps/blog/lib/get-data.tsx @@ -1,5 +1,5 @@ import env from '@beam-australia/react-env'; -import { Entry } from '@transaction/lib/extended-hive.chain'; +import { Entry } from '@transaction/lib/extended-hive.chain'; import { logger } from '@ui/lib/logger'; const apiDevOrigin = env('AI_DOMAIN') || process.env.AI_DOMAIN; @@ -76,3 +76,19 @@ export const getSuggestions = async ({ throw new Error('Error in getSuggestions'); } }; + +export const getThematicAuthors = async (thematic: string, observer: string): Promise => { + try { + const response = await fetch( + `${apiDevOrigin}/hivesense-api/thematiccontributors?thematic=${encodeURIComponent(thematic)}&authors_limit=10&observer=${observer}` + ); + if (!response.ok) { + throw new Error(`Authors API Error: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + logger.error('Error in getAuthors', error); + throw new Error('Error in getAuthors'); + } +}; diff --git a/apps/blog/pages/search.tsx b/apps/blog/pages/search.tsx index 74b5c12e7e9ebaa74201395dbee1a85588d23996..67acc33f37c3cdcc9fa1159ac57155df4637c9d5 100644 --- a/apps/blog/pages/search.tsx +++ b/apps/blog/pages/search.tsx @@ -8,7 +8,6 @@ import { useInView } from 'react-intersection-observer'; import Head from 'next/head'; import PostList from '../components/post-list'; import { getSearch } from '@transaction/lib/bridge'; -import { ModeSwitchInput } from '@ui/components/mode-switch-input'; import { useLocalStorage } from 'usehooks-ts'; import { useEffect } from 'react'; import { useUser } from '@smart-signer/lib/auth/use-user'; @@ -19,10 +18,13 @@ import { useTranslation } from 'next-i18next'; import SearchCard from '../components/search-card'; import { toast } from '@ui/components/hooks/use-toast'; import { getHiveSenseStatus, getSimilarPosts } from '../lib/get-data'; +import SearchBar from '../feature/search/search-bar'; export const getServerSideProps: GetServerSideProps = getDefaultProps; + const PER_PAGE = 20; const TAB_TITLE = 'Search - Hive'; + export default function SearchPage() { const router = useRouter(); const { ref, inView } = useInView(); @@ -31,7 +33,7 @@ export default function SearchPage() { const { t } = useTranslation('common_blog'); const query = router.query.q as string; const sort = router.query.s as string; - const aiSearch = !!query && !sort; + const aiQuery = router.query.ai as string; const { data: hiveSense, isLoading: hiveSenseLoading } = useQuery( ['hivesense-api'], () => getHiveSenseStatus(), @@ -41,34 +43,34 @@ export default function SearchPage() { refetchOnMount: false } ); - - const { data, isLoading, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery( - ['similarPosts', query], - async ({ pageParam }: { pageParam?: { author: string; permlink: string } }) => { - return await getSimilarPosts({ - pattern: query, - observer: user.username !== '' ? user.username : 'hive.blog', - start_permlink: pageParam?.permlink ?? '', - start_author: pageParam?.author ?? '', - limit: PER_PAGE - }); - }, - { - getNextPageParam: (lastPage) => { - if (lastPage && lastPage.length === PER_PAGE) { - return { - author: lastPage[lastPage.length - 1].author, - permlink: lastPage[lastPage.length - 1].permlink - }; - } + const { data, isLoading, isFetching, isError, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteQuery( + ['similarPosts', aiQuery], + async ({ pageParam }: { pageParam?: { author: string; permlink: string } }) => { + return await getSimilarPosts({ + pattern: aiQuery, + observer: user.username !== '' ? user.username : 'hive.blog', + start_permlink: pageParam?.permlink ?? '', + start_author: pageParam?.author ?? '', + limit: PER_PAGE + }); }, + { + getNextPageParam: (lastPage) => { + if (lastPage && lastPage.length === PER_PAGE) { + return { + author: lastPage[lastPage.length - 1].author, + permlink: lastPage[lastPage.length - 1].permlink + }; + } + }, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - enabled: aiSearch - } - ); + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + enabled: !!aiQuery + } + ); const { data: entriesData, isLoading: entriesDataIsLoading, @@ -82,7 +84,7 @@ export default function SearchPage() { (lastPage) => getSearch(query, lastPage.pageParam, sort), { getNextPageParam: (lastPage) => lastPage.scroll_id, - enabled: Boolean(sort) && Boolean(query) + enabled: !!sort && !!query } ); useEffect(() => { @@ -144,12 +146,14 @@ export default function SearchPage() {
- +
- {!aiSearch || !query ? null : isLoading ? ( + {!aiQuery ? null : isLoading ? ( + ) : isError ? ( +
Error loading AI search results.
) : data ? ( data.pages.map((page, index) => { return page ? : null; @@ -175,7 +179,7 @@ export default function SearchPage() {
{isFetching && !isFetchingNextPage ? 'Background Updating...' : null}
- {!!sort ? ( + {!!sort && !!query ? ( <> {entriesDataIsError ? null : entriesDataIsLoading ? ( diff --git a/packages/ui/components/dialog.tsx b/packages/ui/components/dialog.tsx index 871ad9ae07da8778179730d232c00c517f33d7ce..d472fb8ea6d22ee79196dc1afaec3635b43a4a76 100644 --- a/packages/ui/components/dialog.tsx +++ b/packages/ui/components/dialog.tsx @@ -47,7 +47,10 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close diff --git a/packages/ui/components/mode-switch-input.tsx b/packages/ui/components/mode-switch-input.tsx index d7fa5d78e73448c948cc4ed9078ff28eb7635b9d..c2c0f6f8dc42f9b92dfd69f5c8d8c7bac7b4dcd0 100644 --- a/packages/ui/components/mode-switch-input.tsx +++ b/packages/ui/components/mode-switch-input.tsx @@ -33,7 +33,6 @@ export function ModeSwitchInput({ className, aiAvailable, isLoading, searchPage />
(!aiAvailable || !!sort ? 'search' : 'ai'); + const secondQuery = router.query.p as string; + const aiQuery = router.query.ai as string; + const [inputValue, setInputValue] = useState(query ?? aiQuery ?? ''); + const [secondInputValue, setSecondInputValue] = useState(secondQuery ?? ''); + const [mode, setMode] = useState(!aiAvailable ? 'classic' : 'ai'); useEffect(() => { - if (aiAvailable && !sort) { + if (aiAvailable) { setMode('ai'); } - }, [aiAvailable, sort]); + }, [aiAvailable]); const handleSearch = (value: string, currentMode: SearchMode) => { - const searchParams = - currentMode === 'search' - ? `q=${encodeURIComponent(value)}&s=${sort ?? 'newest'}` - : `q=${encodeURIComponent(value)}`; - router.push(`/search?${searchParams}`); + switch (currentMode) { + case 'ai': + router.push(`/search?ai=${encodeURIComponent(value)}`); + break; + case 'users': + router.push(`@${encodeURIComponent(value)}`); + break; + case 'userTopics': + router.push(`/search?a=${encodeURIComponent(value)}&p=${encodeURIComponent(secondInputValue)}`); + break; + case 'tags': + router.push(`trending/${encodeURIComponent(value)}`); + break; + case 'community': + router.push(`trending/${encodeURIComponent(value)}`); + break; + case 'classic': + router.push(`/search?q=${encodeURIComponent(value)}&s=trending`); + break; + default: + router.push(`/search?q=${encodeURIComponent(value)}&s=trending`); + break; + } }; return { inputValue, setInputValue, + secondInputValue, + setSecondInputValue, mode, setMode, handleSearch