diff --git a/src/components/GenerateRoadmap/AITermSuggestionInput.tsx b/src/components/GenerateRoadmap/AITermSuggestionInput.tsx new file mode 100644 index 000000000..e54efd0a9 --- /dev/null +++ b/src/components/GenerateRoadmap/AITermSuggestionInput.tsx @@ -0,0 +1,284 @@ +import { + type InputHTMLAttributes, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { cn } from '../../lib/classname'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useDebounceValue } from '../../hooks/use-debounce'; +import { httpGet } from '../../lib/http'; +import { useToast } from '../../hooks/use-toast'; +import { Spinner } from '../ReactIcons/Spinner.tsx'; +import type { PageType } from '../CommandMenu/CommandMenu.tsx'; + +type GetTopAIRoadmapTermResponse = { + _id: string; + term: string; + title: string; + isOfficial: boolean; +}[]; + +type AITermSuggestionInputProps = { + value: string; + onValueChange: (value: string) => void; + onSelect?: (roadmapId: string, roadmapTitle: string) => void; + inputClassName?: string; + wrapperClassName?: string; + placeholder?: string; +} & Omit< + InputHTMLAttributes, + 'onSelect' | 'onChange' | 'className' +>; + +export function AITermSuggestionInput(props: AITermSuggestionInputProps) { + const { + value: defaultValue, + onValueChange, + onSelect, + inputClassName, + wrapperClassName, + placeholder, + ...inputProps + } = props; + + const termCache = useMemo( + () => new Map(), + [], + ); + const [officialRoadmaps, setOfficialRoadmaps] = + useState([]); + + const toast = useToast(); + const searchInputRef = useRef(null); + const dropdownRef = useRef(null); + const isFirstRender = useRef(true); + + const [isActive, setIsActive] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [searchResults, setSearchResults] = + useState([]); + const [searchedText, setSearchedText] = useState(defaultValue); + const [activeCounter, setActiveCounter] = useState(0); + const debouncedSearchValue = useDebounceValue(searchedText, 300); + + const loadTopAIRoadmapTerm = async () => { + const trimmedValue = debouncedSearchValue.trim(); + if (trimmedValue.length === 0) { + return []; + } + + if (termCache.has(trimmedValue)) { + const cachedData = termCache.get(trimmedValue); + return cachedData || []; + } + + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-get-top-ai-roadmap-term`, + { + term: trimmedValue, + }, + ); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + setSearchResults([]); + return []; + } + + termCache.set(trimmedValue, response); + return response; + }; + + const loadOfficialRoadmaps = async () => { + if (officialRoadmaps.length > 0) { + return officialRoadmaps; + } + + const { error, response } = await httpGet(`/pages.json`); + + if (error) { + toast.error(error.message || 'Something went wrong'); + return; + } + + if (!response) { + return []; + } + + const allRoadmaps = response + .filter((page) => page.group === 'Roadmaps') + .sort((a, b) => { + if (a.title === 'Android') return 1; + return a.title.localeCompare(b.title); + }) + .map((page) => ({ + _id: page.id, + term: page.title, + title: page.title, + isOfficial: true, + })); + + setOfficialRoadmaps(allRoadmaps); + return allRoadmaps; + }; + + useEffect(() => { + if (debouncedSearchValue.length === 0 || isFirstRender.current) { + setSearchResults([]); + return; + } + + setIsActive(true); + setIsLoading(true); + loadTopAIRoadmapTerm() + .then((results) => { + const normalizedSearchText = debouncedSearchValue.trim().toLowerCase(); + const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => { + return ( + roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1 + ); + }); + + setSearchResults( + [...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [], + ); + setActiveCounter(0); + }) + .finally(() => { + setIsLoading(false); + }); + }, [debouncedSearchValue]); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + loadOfficialRoadmaps().finally(() => {}); + } + }, []); + + useOutsideClick(dropdownRef, () => { + setIsActive(false); + }); + + const isFinishedTyping = debouncedSearchValue === searchedText; + + return ( +
+ { + const value = (e.target as HTMLInputElement).value; + setSearchedText(value); + onValueChange(value); + }} + onFocus={() => { + setIsActive(true); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + const canGoNext = activeCounter < searchResults.length - 1; + setActiveCounter(canGoNext ? activeCounter + 1 : 0); + } else if (e.key === 'ArrowUp') { + const canGoPrev = activeCounter > 0; + setActiveCounter( + canGoPrev ? activeCounter - 1 : searchResults.length - 1, + ); + } else if (e.key === 'Tab') { + if (isActive) { + e.preventDefault(); + } + } else if (e.key === 'Escape') { + setSearchedText(''); + setIsActive(false); + } else if (e.key === 'Enter') { + if (!searchResults.length || !isFinishedTyping) { + return; + } + + e.preventDefault(); + const activeData = searchResults[activeCounter]; + if (activeData) { + if (activeData.isOfficial) { + window.open(`/${activeData._id}`, '_blank')?.focus(); + return; + } + + onValueChange(activeData.term); + onSelect?.(activeData._id, activeData.title); + setIsActive(false); + } + } + }} + /> + + {isLoading && ( +
+ +
+ )} + + {isActive && + isFinishedTyping && + searchResults.length > 0 && + searchedText.length > 0 && ( +
+
+ {searchResults.map((result, counter) => { + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx index fae9ea50f..875d36905 100644 --- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -36,6 +36,8 @@ import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx'; import { AIRoadmapAlert } from './AIRoadmapAlert.tsx'; import { OpenAISettings } from './OpenAISettings.tsx'; import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts'; +import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; +import { useParams } from '../../hooks/use-params.ts'; export type GetAIRoadmapLimitResponse = { used: number; @@ -90,6 +92,7 @@ export function GenerateRoadmap() { const [hasSubmitted, setHasSubmitted] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isLoadingResults, setIsLoadingResults] = useState(false); const [roadmapTerm, setRoadmapTerm] = useState(''); const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState(''); const [currentRoadmap, setCurrentRoadmap] = @@ -120,12 +123,6 @@ export function GenerateRoadmap() { setIsLoading(true); setHasSubmitted(true); - if (roadmapLimitUsed >= roadmapLimit) { - toast.error('You have reached your limit of generating roadmaps'); - setIsLoading(false); - return; - } - deleteUrlParam('id'); setCurrentRoadmap(null); @@ -171,10 +168,13 @@ export function GenerateRoadmap() { const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || ''; setUrlParams({ id: roadmapId }); result = result.replace(ROADMAP_ID_REGEX, ''); + const roadmapTitle = + result.trim().split('\n')[0]?.replace('#', '')?.trim() || term; + setRoadmapTerm(roadmapTitle); setCurrentRoadmap({ id: roadmapId, term: roadmapTerm, - title: term, + title: roadmapTitle, data: result, }); } @@ -193,11 +193,11 @@ export function GenerateRoadmap() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!roadmapTerm) { + if (!roadmapTerm || isLoadingResults) { return; } - if (roadmapTerm === currentRoadmap?.topic) { + if (roadmapTerm === currentRoadmap?.term) { return; } @@ -293,7 +293,8 @@ export function GenerateRoadmap() { pageProgressMessage.set('Loading Roadmap'); const { response, error } = await httpGet<{ - topic: string; + term: string; + title: string; data: string; }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`); @@ -479,7 +480,7 @@ export function GenerateRoadmap() { > {roadmapLimitUsed} of {roadmapLimit} {' '} - roadmaps generated. + roadmaps generated today. {!openAPIKey && (