Skip to main content
Glama
ProjectMenuOptions.tsx7.03 kB
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons"; import { Button } from "@ui/Button"; import { Spinner } from "@ui/Spinner"; import { useCurrentProject, useInfiniteProjects } from "api/projects"; import { useState, useMemo, useRef } from "react"; import { TeamResponse, ProjectDetails } from "generatedApi"; import classNames from "classnames"; import { SelectorItem } from "elements/SelectorItem"; import { useDeploymentUris } from "hooks/useDeploymentUris"; import { useLastViewedDeploymentForProject } from "hooks/useLastViewed"; import { InfiniteScrollList } from "@common/elements/InfiniteScrollList"; import { OpenInVercel } from "components/OpenInVercel"; import startCase from "lodash/startCase"; const PROJECT_SELECTOR_ITEM_SIZE = 44; export function ProjectMenuOptions({ team, onCreateProjectClick, close, }: { team: TeamResponse; onCreateProjectClick: (team: TeamResponse) => void; close(): void; }) { const currentProject = useCurrentProject(); const [projectQuery, setProjectQuery] = useState(""); const { projects: projectsForCurrentTeam, isLoading, hasMore, loadMore, debouncedQuery, pageSize, } = useInfiniteProjects(team.id, projectQuery); const items = useMemo(() => { if (!projectsForCurrentTeam || projectsForCurrentTeam.length === 0) return []; const result = []; // Check if current project is in the results const currentProjectInResults = projectsForCurrentTeam.find( (p) => p.slug === currentProject?.slug, ); if (currentProjectInResults) { result.push({ ...currentProjectInResults, _isCurrent: true }); } // Add all other projects result.push( ...projectsForCurrentTeam.filter((p) => p.slug !== currentProject?.slug), ); return result; }, [projectsForCurrentTeam, currentProject?.slug]); const itemData = useMemo( () => ({ items, team, close, }), [items, team, close], ); const itemKey = useMemo( () => (idx: number, data: typeof itemData) => data.items[idx]?.id?.toString() || `loading-${idx}`, [], ); const scrollRef = useRef<HTMLDivElement>(null); return ( <> <div className="sticky top-0 z-10 flex w-full items-center gap-2 border-b bg-background-secondary px-3"> {isLoading && debouncedQuery === projectQuery ? ( <div className="animate-fadeInFromLoading"> <Spinner className="size-3" /> </div> ) : ( <MagnifyingGlassIcon className="animate-fadeInFromLoading text-content-secondary" /> )} <div className="relative flex w-full items-center"> <input autoFocus onChange={(e) => { setProjectQuery(e.target.value); }} value={projectQuery} className={classNames( "placeholder:text-content-tertiary truncate relative w-full py-1.5 text-left text-xs text-content-primary disabled:bg-background-tertiary disabled:text-content-secondary disabled:cursor-not-allowed", "focus:outline-hidden bg-background-secondary font-normal", )} placeholder="Search projects..." /> </div> </div> <label className="px-2 pt-1 text-xs font-semibold text-content-secondary" htmlFor="project-menu-options" > Projects </label> {items.length === 0 && !isLoading && debouncedQuery.trim() ? ( <div className="flex w-full animate-fadeInFromLoading items-center justify-center py-4 text-xs text-content-secondary"> No projects match your search. </div> ) : ( <div id="project-menu-options" className="w-full" style={{ height: Math.min( items.length * PROJECT_SELECTOR_ITEM_SIZE, 22 * 16, ), }} role="menu" > <InfiniteScrollList outerRef={scrollRef} items={items} totalNumItems={hasMore ? items.length + 1 : items.length} itemSize={PROJECT_SELECTOR_ITEM_SIZE} itemData={itemData} RowOrLoading={ProjectSelectorListItem} overscanCount={25} loadMoreThreshold={1} loadMore={loadMore} pageSize={pageSize} itemKey={itemKey} /> </div> )} <div className="flex w-full gap-2 p-2"> <Button inline onClick={() => { onCreateProjectClick(team); close(); }} icon={<PlusIcon aria-hidden="true" />} className="grow" size="sm" disabled={!!team.managedBy} tip={ team.managedBy ? `This team is managed by ${startCase(team.managedBy)}. You can create new projects through the ${startCase(team.managedBy)} dashboard.` : "" } > Create Project </Button> <OpenInVercel team={team} /> </div> </> ); } function ProjectSelectorListItem({ index, style, data, }: { index: number; style: React.CSSProperties; data: { items: (ProjectDetails & { _isCurrent?: boolean })[]; team: TeamResponse; close: () => void; }; }) { const { items, team, close } = data; const project = items[index]; // Handle loading state or missing project if (!project) { return <div style={style} />; } // _isCurrent is only set for the current project const selected = Boolean(project._isCurrent); return ( <div style={style}> <ProjectSelectorItem selected={selected} close={close} project={project} key={project.id} teamSlug={team.slug} /> </div> ); } function ProjectSelectorItem({ project, teamSlug, close, selected = false, active = false, onFocusOrMouseEnter, optionRef, }: { project: ProjectDetails; teamSlug?: string; close: () => void; selected?: boolean; active?: boolean; onFocusOrMouseEnter?: () => void; optionRef?: React.RefObject<HTMLDivElement>; }) { const { generateHref, defaultHref } = useDeploymentUris( project.id, project.slug, teamSlug, ); const [lastViewedDeployment] = useLastViewedDeploymentForProject( project.slug, ); return ( <div className="flex w-full gap-0.5 p-0.5" style={{ height: PROJECT_SELECTOR_ITEM_SIZE, }} ref={active ? optionRef : undefined} > <SelectorItem className="grow" href={ lastViewedDeployment ? generateHref(lastViewedDeployment) : defaultHref! } active={active} selected={selected} close={close} key={project.slug} onFocusOrMouseEnter={onFocusOrMouseEnter} eventName="switch project" > <span className="truncate">{project.name}</span> </SelectorItem> </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server