mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-29 12:10:22 +02:00
feat: show pre-existing results on AI search input (#5349)
* feat: ai term suggestion input * fix: add suggestion for roadmap * Update spinner * fix: hydration errors * Refactor roadmap search and suggestions * Remove limit from frontend * Update roadmap title --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
284
src/components/GenerateRoadmap/AITermSuggestionInput.tsx
Normal file
284
src/components/GenerateRoadmap/AITermSuggestionInput.tsx
Normal file
@@ -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<HTMLInputElement>,
|
||||||
|
'onSelect' | 'onChange' | 'className'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
|
||||||
|
const {
|
||||||
|
value: defaultValue,
|
||||||
|
onValueChange,
|
||||||
|
onSelect,
|
||||||
|
inputClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
placeholder,
|
||||||
|
...inputProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const termCache = useMemo(
|
||||||
|
() => new Map<string, GetTopAIRoadmapTermResponse>(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [officialRoadmaps, setOfficialRoadmaps] =
|
||||||
|
useState<GetTopAIRoadmapTermResponse>([]);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isFirstRender = useRef(true);
|
||||||
|
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] =
|
||||||
|
useState<GetTopAIRoadmapTermResponse>([]);
|
||||||
|
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<GetTopAIRoadmapTermResponse>(
|
||||||
|
`${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<PageType[]>(`/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 (
|
||||||
|
<div className={cn('relative', wrapperClassName)}>
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={defaultValue}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border border-gray-400 px-3 py-2.5 pr-8 transition-colors focus:border-black focus:outline-none',
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<div className="absolute right-3 top-0 flex h-full items-center">
|
||||||
|
<Spinner
|
||||||
|
isDualRing={false}
|
||||||
|
className="h-5 w-5 animate-spin stroke-[2.5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive &&
|
||||||
|
isFinishedTyping &&
|
||||||
|
searchResults.length > 0 &&
|
||||||
|
searchedText.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full z-50 mt-1 w-full rounded-md border bg-white p-1 shadow"
|
||||||
|
ref={dropdownRef}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{searchResults.map((result, counter) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={result?._id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center rounded p-2 text-sm',
|
||||||
|
counter === activeCounter ? 'bg-gray-100' : '',
|
||||||
|
)}
|
||||||
|
onMouseOver={() => setActiveCounter(counter)}
|
||||||
|
onClick={() => {
|
||||||
|
if (result.isOfficial) {
|
||||||
|
window.location.href = `/${result._id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange(result?.term);
|
||||||
|
onSelect?.(result._id, result.title);
|
||||||
|
setSearchedText('');
|
||||||
|
setIsActive(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mr-2 rounded-full p-1 px-1.5 text-xs leading-none',
|
||||||
|
result.isOfficial
|
||||||
|
? 'bg-green-500 text-green-50'
|
||||||
|
: 'bg-blue-400 text-blue-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.isOfficial ? 'Official' : 'AI Generated'}
|
||||||
|
</span>
|
||||||
|
{result?.title || result?.term}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -36,6 +36,8 @@ import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
|||||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||||
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
|
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 = {
|
export type GetAIRoadmapLimitResponse = {
|
||||||
used: number;
|
used: number;
|
||||||
@@ -90,6 +92,7 @@ export function GenerateRoadmap() {
|
|||||||
|
|
||||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
|
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
||||||
const [roadmapTerm, setRoadmapTerm] = useState('');
|
const [roadmapTerm, setRoadmapTerm] = useState('');
|
||||||
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
||||||
const [currentRoadmap, setCurrentRoadmap] =
|
const [currentRoadmap, setCurrentRoadmap] =
|
||||||
@@ -120,12 +123,6 @@ export function GenerateRoadmap() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setHasSubmitted(true);
|
setHasSubmitted(true);
|
||||||
|
|
||||||
if (roadmapLimitUsed >= roadmapLimit) {
|
|
||||||
toast.error('You have reached your limit of generating roadmaps');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteUrlParam('id');
|
deleteUrlParam('id');
|
||||||
setCurrentRoadmap(null);
|
setCurrentRoadmap(null);
|
||||||
|
|
||||||
@@ -171,10 +168,13 @@ export function GenerateRoadmap() {
|
|||||||
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
||||||
setUrlParams({ id: roadmapId });
|
setUrlParams({ id: roadmapId });
|
||||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||||
|
const roadmapTitle =
|
||||||
|
result.trim().split('\n')[0]?.replace('#', '')?.trim() || term;
|
||||||
|
setRoadmapTerm(roadmapTitle);
|
||||||
setCurrentRoadmap({
|
setCurrentRoadmap({
|
||||||
id: roadmapId,
|
id: roadmapId,
|
||||||
term: roadmapTerm,
|
term: roadmapTerm,
|
||||||
title: term,
|
title: roadmapTitle,
|
||||||
data: result,
|
data: result,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -193,11 +193,11 @@ export function GenerateRoadmap() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!roadmapTerm) {
|
if (!roadmapTerm || isLoadingResults) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roadmapTerm === currentRoadmap?.topic) {
|
if (roadmapTerm === currentRoadmap?.term) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +293,8 @@ export function GenerateRoadmap() {
|
|||||||
pageProgressMessage.set('Loading Roadmap');
|
pageProgressMessage.set('Loading Roadmap');
|
||||||
|
|
||||||
const { response, error } = await httpGet<{
|
const { response, error } = await httpGet<{
|
||||||
topic: string;
|
term: string;
|
||||||
|
title: string;
|
||||||
data: string;
|
data: string;
|
||||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`);
|
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`);
|
||||||
|
|
||||||
@@ -479,7 +480,7 @@ export function GenerateRoadmap() {
|
|||||||
>
|
>
|
||||||
{roadmapLimitUsed} of {roadmapLimit}
|
{roadmapLimitUsed} of {roadmapLimit}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
roadmaps generated.
|
roadmaps generated today.
|
||||||
</span>
|
</span>
|
||||||
{!openAPIKey && (
|
{!openAPIKey && (
|
||||||
<button
|
<button
|
||||||
@@ -516,15 +517,14 @@ export function GenerateRoadmap() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
|
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
|
||||||
>
|
>
|
||||||
<input
|
<AITermSuggestionInput
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
placeholder="e.g. Try searching for Ansible or DevOps"
|
|
||||||
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none"
|
|
||||||
value={roadmapTerm}
|
value={roadmapTerm}
|
||||||
onInput={(e) =>
|
onValueChange={(value) => setRoadmapTerm(value)}
|
||||||
setRoadmapTerm((e.target as HTMLInputElement).value)
|
placeholder="e.g. Try searching for Ansible or DevOps"
|
||||||
}
|
wrapperClassName="grow"
|
||||||
|
onSelect={(id, title) => {
|
||||||
|
loadTermRoadmap(title).finally(() => null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type={'submit'}
|
type={'submit'}
|
||||||
@@ -539,37 +539,47 @@ export function GenerateRoadmap() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
isAuthenticatedUser &&
|
isLoadingResults ||
|
||||||
(!roadmapLimit ||
|
(isAuthenticatedUser &&
|
||||||
!roadmapTerm ||
|
(!roadmapLimit ||
|
||||||
roadmapLimitUsed >= roadmapLimit ||
|
!roadmapTerm ||
|
||||||
roadmapTerm === currentRoadmap?.term ||
|
roadmapLimitUsed >= roadmapLimit ||
|
||||||
(isKeyOnly && !openAPIKey))
|
roadmapTerm === currentRoadmap?.term ||
|
||||||
|
(isKeyOnly && !openAPIKey)))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isAuthenticatedUser && (
|
{isLoadingResults && (
|
||||||
<>
|
<>
|
||||||
<Wand size={20} />
|
<span>Please wait..</span>
|
||||||
Generate
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isLoadingResults && (
|
||||||
{isAuthenticatedUser && (
|
|
||||||
<>
|
<>
|
||||||
{roadmapLimit > 0 && canGenerateMore && (
|
{!isAuthenticatedUser && (
|
||||||
<>
|
<>
|
||||||
<Wand size={20} />
|
<Wand size={20} />
|
||||||
Generate
|
Generate
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{roadmapLimit === 0 && <span>Please wait..</span>}
|
{isAuthenticatedUser && (
|
||||||
|
<>
|
||||||
|
{roadmapLimit > 0 && canGenerateMore && (
|
||||||
|
<>
|
||||||
|
<Wand size={20} />
|
||||||
|
Generate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{roadmapLimit > 0 && !canGenerateMore && (
|
{roadmapLimit === 0 && <span>Please wait..</span>}
|
||||||
<span className="flex items-center">
|
|
||||||
<Ban size={15} className="mr-2" />
|
{roadmapLimit > 0 && !canGenerateMore && (
|
||||||
Limit reached
|
<span className="flex items-center">
|
||||||
</span>
|
<Ban size={15} className="mr-2" />
|
||||||
|
Limit reached
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@@ -1,17 +1,11 @@
|
|||||||
import {
|
import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
|
||||||
ArrowUpRight,
|
|
||||||
Ban,
|
|
||||||
CircleFadingPlus,
|
|
||||||
Cog,
|
|
||||||
Telescope,
|
|
||||||
Wand,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
|
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { cn } from '../../lib/classname.ts';
|
import { cn } from '../../lib/classname.ts';
|
||||||
import { useState } from 'react';
|
|
||||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||||
|
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||||
|
|
||||||
type RoadmapSearchProps = {
|
type RoadmapSearchProps = {
|
||||||
roadmapTerm: string;
|
roadmapTerm: string;
|
||||||
@@ -38,8 +32,14 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
|
|
||||||
const canGenerateMore = limitUsed < limit;
|
const canGenerateMore = limitUsed < limit;
|
||||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||||
const openAPIKey = getOpenAIKey();
|
const [openAPIKey, setOpenAPIKey] = useState('');
|
||||||
const isAuthenticatedUser = isLoggedIn();
|
const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
|
||||||
|
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenAPIKey(getOpenAIKey() || '');
|
||||||
|
setIsAuthenticatedUser(isLoggedIn());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||||
|
|
||||||
@@ -78,15 +78,15 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
}}
|
}}
|
||||||
className="flex w-full flex-col gap-2 sm:flex-row"
|
className="flex w-full flex-col gap-2 sm:flex-row"
|
||||||
>
|
>
|
||||||
<input
|
<AITermSuggestionInput
|
||||||
autoFocus
|
autoFocus={true}
|
||||||
type="text"
|
|
||||||
placeholder="Enter a topic to generate a roadmap for"
|
|
||||||
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
|
|
||||||
value={roadmapTerm}
|
value={roadmapTerm}
|
||||||
onInput={(e) =>
|
onValueChange={(value) => setRoadmapTerm(value)}
|
||||||
setRoadmapTerm((e.target as HTMLInputElement).value)
|
placeholder="Enter a topic to generate a roadmap for"
|
||||||
}
|
wrapperClassName="w-full"
|
||||||
|
onSelect={(roadmapId, roadmapTitle) => {
|
||||||
|
onLoadTerm(roadmapTitle);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -100,33 +100,44 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
isAuthenticatedUser &&
|
isLoadingResults ||
|
||||||
(!limit ||
|
(isAuthenticatedUser &&
|
||||||
!roadmapTerm ||
|
(!limit ||
|
||||||
limitUsed >= limit ||
|
!roadmapTerm ||
|
||||||
(isKeyOnly && !openAPIKey))
|
limitUsed >= limit ||
|
||||||
|
(isKeyOnly && !openAPIKey)))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isAuthenticatedUser && (
|
{isLoadingResults && (
|
||||||
<>
|
<>
|
||||||
<Wand size={20} />
|
<span>Please wait..</span>
|
||||||
Generate
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isAuthenticatedUser && (
|
|
||||||
|
{!isLoadingResults && (
|
||||||
<>
|
<>
|
||||||
{(!limit || canGenerateMore) && (
|
{!isAuthenticatedUser && (
|
||||||
<>
|
<>
|
||||||
<Wand size={20} />
|
<Wand size={20} />
|
||||||
Generate
|
Generate
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isAuthenticatedUser && (
|
||||||
|
<>
|
||||||
|
{(!limit || canGenerateMore) && (
|
||||||
|
<>
|
||||||
|
<Wand size={20} />
|
||||||
|
Generate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{limit > 0 && !canGenerateMore && (
|
{limit > 0 && !canGenerateMore && (
|
||||||
<span className="flex items-center text-base">
|
<span className="flex items-center text-base">
|
||||||
<Ban size={15} className="mr-2" />
|
<Ban size={15} className="mr-2" />
|
||||||
Limit reached
|
Limit reached
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -242,7 +253,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
>
|
>
|
||||||
{limitUsed} of {limit}
|
{limitUsed} of {limit}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
roadmaps.
|
roadmaps today.
|
||||||
</p>
|
</p>
|
||||||
{isAuthenticatedUser && (
|
{isAuthenticatedUser && (
|
||||||
<p className="flex items-center text-sm">
|
<p className="flex items-center text-sm">
|
||||||
|
17
src/hooks/use-debounce.ts
Normal file
17
src/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useDebounceValue<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
Reference in New Issue
Block a user