diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e9721a29..d9048532 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -31,6 +31,7 @@ "date-fns": "^3.3.1", "downshift": "^8.3.2", "eslint-config-react-app": "^7.0.1", + "framer-motion": "^11.0.8", "gql-client": "^1.0.0", "lottie-react": "^2.4.0", "luxon": "^3.4.4", diff --git a/packages/frontend/src/components/Logo.tsx b/packages/frontend/src/components/Logo.tsx new file mode 100644 index 00000000..a46607c3 --- /dev/null +++ b/packages/frontend/src/components/Logo.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Heading } from './shared/Heading'; + +interface LogoProps { + orgSlug?: string; +} + +export const Logo = ({ orgSlug }: LogoProps) => { + return ( + +
+ Snowball Logo + + Snowball + +
+ + ); +}; diff --git a/packages/frontend/src/components/SearchBar.tsx b/packages/frontend/src/components/SearchBar.tsx index d597f2c1..a81b9f98 100644 --- a/packages/frontend/src/components/SearchBar.tsx +++ b/packages/frontend/src/components/SearchBar.tsx @@ -15,7 +15,8 @@ const SearchBar: React.ForwardRefRenderFunction< value={value} type="search" placeholder={placeholder} - appearance={'borderless'} + appearance="borderless" + className="w-full lg:w-[459px]" {...props} /> diff --git a/packages/frontend/src/components/projects/ProjectSearchBar.tsx b/packages/frontend/src/components/projects/ProjectSearchBar.tsx deleted file mode 100644 index 56249356..00000000 --- a/packages/frontend/src/components/projects/ProjectSearchBar.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useCombobox } from 'downshift'; -import { Project } from 'gql-client'; -import { useDebounce } from 'usehooks-ts'; - -import { - List, - ListItem, - ListItemPrefix, - Card, - Typography, - Avatar, -} from '@material-tailwind/react'; - -import SearchBar from '../SearchBar'; -import { useGQLClient } from '../../context/GQLClientContext'; - -interface ProjectsSearchProps { - onChange?: (data: Project) => void; -} - -const ProjectSearchBar = ({ onChange }: ProjectsSearchProps) => { - const [items, setItems] = useState([]); - const [selectedItem, setSelectedItem] = useState(null); - const client = useGQLClient(); - - const { - isOpen, - getMenuProps, - getInputProps, - getItemProps, - highlightedIndex, - inputValue, - } = useCombobox({ - items, - itemToString(item) { - return item ? item.name : ''; - }, - selectedItem, - onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { - if (newSelectedItem) { - setSelectedItem(newSelectedItem); - - if (onChange) { - onChange(newSelectedItem); - } - } - }, - }); - - const debouncedInputValue = useDebounce(inputValue, 500); - - const fetchProjects = useCallback( - async (inputValue: string) => { - const { searchProjects } = await client.searchProjects(inputValue); - setItems(searchProjects); - }, - [client], - ); - - useEffect(() => { - if (debouncedInputValue) { - fetchProjects(debouncedInputValue); - } - }, [fetchProjects, debouncedInputValue]); - - return ( -
- - - - {items.length ? ( - <> -
- - Suggestions - -
- {items.map((item, index) => ( - - - - -
- - {item.name} - - - {item.organization.name} - -
-
- ))} - - ) : ( -
- - ^ No projects matching this name - -
- )} -
-
-
- ); -}; - -export default ProjectSearchBar; diff --git a/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBar.tsx b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBar.tsx new file mode 100644 index 00000000..22e8b2f7 --- /dev/null +++ b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBar.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useCombobox } from 'downshift'; +import { Project } from 'gql-client'; +import { useDebounce } from 'usehooks-ts'; + +import SearchBar from 'components/SearchBar'; +import { useGQLClient } from 'context/GQLClientContext'; +import { cn } from 'utils/classnames'; +import { ProjectSearchBarItem } from './ProjectSearchBarItem'; +import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty'; + +interface ProjectSearchBarProps { + onChange?: (data: Project) => void; +} + +export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => { + const [items, setItems] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const client = useGQLClient(); + + const { + isOpen, + getMenuProps, + getInputProps, + getItemProps, + highlightedIndex, + inputValue, + } = useCombobox({ + items, + itemToString(item) { + return item ? item.name : ''; + }, + selectedItem, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + if (newSelectedItem) { + setSelectedItem(newSelectedItem); + + if (onChange) { + onChange(newSelectedItem); + } + } + }, + }); + + const debouncedInputValue = useDebounce(inputValue, 300); + + const fetchProjects = useCallback( + async (inputValue: string) => { + const { searchProjects } = await client.searchProjects(inputValue); + setItems(searchProjects); + }, + [client], + ); + + useEffect(() => { + if (debouncedInputValue) { + fetchProjects(debouncedInputValue); + } + }, [fetchProjects, debouncedInputValue]); + + return ( +
+ +
+ {items.length ? ( + <> +
+

+ Suggestions +

+
+ {items.map((item, index) => ( + + ))} + + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarDialog.tsx b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarDialog.tsx new file mode 100644 index 00000000..e49b44d0 --- /dev/null +++ b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarDialog.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Button } from 'components/shared/Button'; +import { CrossIcon, SearchIcon } from 'components/shared/CustomIcon'; +import { Input } from 'components/shared/Input'; +import { useGQLClient } from 'context/GQLClientContext'; +import { Project } from 'gql-client'; +import { useDebounce } from 'usehooks-ts'; +import { ProjectSearchBarItem } from './ProjectSearchBarItem'; +import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty'; +import { useNavigate } from 'react-router-dom'; +import { useCombobox } from 'downshift'; + +interface ProjectSearchBarDialogProps extends Dialog.DialogProps { + onClose?: () => void; + onClickItem?: (data: Project) => void; +} + +export const ProjectSearchBarDialog = ({ + onClose, + onClickItem, + ...props +}: ProjectSearchBarDialogProps) => { + const [items, setItems] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const client = useGQLClient(); + const navigate = useNavigate(); + + const { getInputProps, getItemProps, inputValue, setInputValue } = + useCombobox({ + items, + itemToString(item) { + return item ? item.name : ''; + }, + selectedItem, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + if (newSelectedItem) { + setSelectedItem(newSelectedItem); + onClickItem?.(newSelectedItem); + navigate( + `/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`, + ); + } + }, + }); + + const debouncedInputValue = useDebounce(inputValue, 300); + + const fetchProjects = useCallback( + async (inputValue: string) => { + const { searchProjects } = await client.searchProjects(inputValue); + setItems(searchProjects); + }, + [client], + ); + + useEffect(() => { + if (debouncedInputValue) { + fetchProjects(debouncedInputValue); + } + }, [fetchProjects, debouncedInputValue]); + + const handleClose = () => { + setInputValue(''); + setItems([]); + onClose?.(); + }; + + return ( + + + + +
+
+ } + placeholder="Search" + appearance="borderless" + autoFocus + /> + +
+ {/* Content */} +
+ {items.length > 0 + ? items.map((item, index) => ( + <> +
+

+ Suggestions +

+
+ + + )) + : inputValue && } +
+
+
+
+
+ ); +}; diff --git a/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarEmpty.tsx b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarEmpty.tsx new file mode 100644 index 00000000..342969c2 --- /dev/null +++ b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarEmpty.tsx @@ -0,0 +1,24 @@ +import { InfoRoundFilledIcon } from 'components/shared/CustomIcon'; +import React, { ComponentPropsWithoutRef } from 'react'; +import { cn } from 'utils/classnames'; + +interface ProjectSearchBarEmptyProps extends ComponentPropsWithoutRef<'div'> {} + +export const ProjectSearchBarEmpty = ({ + className, + ...props +}: ProjectSearchBarEmptyProps) => { + return ( +
+
+ +
+

+ No projects matching this name +

+
+ ); +}; diff --git a/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarItem.tsx b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarItem.tsx new file mode 100644 index 00000000..f97419ff --- /dev/null +++ b/packages/frontend/src/components/projects/ProjectSearchBar/ProjectSearchBarItem.tsx @@ -0,0 +1,59 @@ +import { Avatar } from 'components/shared/Avatar'; +import { Overwrite, UseComboboxGetItemPropsReturnValue } from 'downshift'; +import { Project } from 'gql-client'; +import React, { ComponentPropsWithoutRef, forwardRef } from 'react'; +import { OmitCommon } from 'types/common'; +import { cn } from 'utils/classnames'; +import { getInitials } from 'utils/geInitials'; + +/** + * Represents a type that merges ComponentPropsWithoutRef<'li'> with certain exclusions. + * @type {MergedComponentPropsWithoutRef} + */ +type MergedComponentPropsWithoutRef = OmitCommon< + ComponentPropsWithoutRef<'button'>, + Omit< + Overwrite, + 'index' | 'item' + > +>; + +interface ProjectSearchBarItemProps extends MergedComponentPropsWithoutRef { + item: Project; + active?: boolean; +} + +const ProjectSearchBarItem = forwardRef< + HTMLButtonElement, + ProjectSearchBarItemProps +>(({ item, active, ...props }, ref) => { + return ( + + ); +}); + +ProjectSearchBarItem.displayName = 'ProjectSearchBarItem'; + +export { ProjectSearchBarItem }; diff --git a/packages/frontend/src/components/projects/ProjectSearchBar/index.ts b/packages/frontend/src/components/projects/ProjectSearchBar/index.ts new file mode 100644 index 00000000..6fd8929b --- /dev/null +++ b/packages/frontend/src/components/projects/ProjectSearchBar/index.ts @@ -0,0 +1,2 @@ +export * from './ProjectSearchBar'; +export * from './ProjectSearchBarDialog'; diff --git a/packages/frontend/src/components/shared/CustomIcon/LogoutIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/LogoutIcon.tsx new file mode 100644 index 00000000..ec3ff102 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/LogoutIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const LogoutIcon: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/MenuIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/MenuIcon.tsx new file mode 100644 index 00000000..4cdefc06 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/MenuIcon.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const MenuIcon = (props: CustomIconProps) => { + return ( + + + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/SearchIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/SearchIcon.tsx index 7688adcd..b72c059b 100644 --- a/packages/frontend/src/components/shared/CustomIcon/SearchIcon.tsx +++ b/packages/frontend/src/components/shared/CustomIcon/SearchIcon.tsx @@ -4,17 +4,17 @@ import { CustomIcon, CustomIconProps } from './CustomIcon'; export const SearchIcon = (props: CustomIconProps) => { return ( ); diff --git a/packages/frontend/src/components/shared/CustomIcon/index.ts b/packages/frontend/src/components/shared/CustomIcon/index.ts index 74e3cbfe..29e38448 100644 --- a/packages/frontend/src/components/shared/CustomIcon/index.ts +++ b/packages/frontend/src/components/shared/CustomIcon/index.ts @@ -55,6 +55,8 @@ export * from './UndoIcon'; export * from './LoaderIcon'; export * from './MinusCircleIcon'; export * from './CopyIcon'; +export * from './MenuIcon'; +export * from './LogoutIcon'; export * from './CirclePlaceholderOnIcon'; export * from './WarningTriangleIcon'; export * from './CheckRadioOutlineIcon'; diff --git a/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx b/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx index 17b56dca..5dd1ed2d 100644 --- a/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx +++ b/packages/frontend/src/components/shared/Sidebar/Sidebar.tsx @@ -1,26 +1,49 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, NavLink, useNavigate, useParams } from 'react-router-dom'; -import { Organization } from 'gql-client'; +import { NavLink, useNavigate, useParams } from 'react-router-dom'; +import { Organization, User } from 'gql-client'; +import { motion } from 'framer-motion'; import { useDisconnect } from 'wagmi'; import { useGQLClient } from 'context/GQLClientContext'; import { - FolderIcon, GlobeIcon, LifeBuoyIcon, + LogoutIcon, QuestionMarkRoundIcon, - SettingsSlidersIcon, } from 'components/shared/CustomIcon'; import { Tabs } from 'components/shared/Tabs'; -import { Heading } from 'components/shared/Heading'; +import { Logo } from 'components/Logo'; +import { Avatar } from 'components/shared/Avatar'; +import { formatAddress } from 'utils/format'; +import { getInitials } from 'utils/geInitials'; +import { Button } from 'components/shared/Button'; +import { cn } from 'utils/classnames'; +import { useMediaQuery } from 'usehooks-ts'; +import { SIDEBAR_MENU } from './constants'; import { UserSelect } from 'components/shared/UserSelect'; -export const Sidebar = () => { +interface SidebarProps { + mobileOpen?: boolean; +} + +export const Sidebar = ({ mobileOpen }: SidebarProps) => { const { orgSlug } = useParams(); const navigate = useNavigate(); const client = useGQLClient(); const { disconnect } = useDisconnect(); + const isDesktop = useMediaQuery('(min-width: 960px)'); + + const [user, setUser] = useState(); + + const fetchUser = useCallback(async () => { + const { user } = await client.getUser(); + setUser(user); + }, []); + + useEffect(() => { + fetchUser(); + }, []); const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug); const [organizations, setOrganizations] = useState([]); @@ -43,6 +66,7 @@ export const Sidebar = () => { imgSrc: '/logo.svg', }; }, [organizations, selectedOrgSlug, orgSlug]); + const formattedSelectOptions = useMemo(() => { return organizations.map((org) => ({ value: org.slug, @@ -51,68 +75,100 @@ export const Sidebar = () => { })); }, [organizations, selectedOrgSlug, orgSlug]); + const renderMenu = useMemo(() => { + return SIDEBAR_MENU(orgSlug).map(({ title, icon, url }, index) => ( + + + {title} + + + )); + }, [orgSlug]); + const handleLogOut = useCallback(() => { disconnect(); navigate('/login'); }, [disconnect, navigate]); return ( - + ); }; diff --git a/packages/frontend/src/components/shared/Sidebar/constants.tsx b/packages/frontend/src/components/shared/Sidebar/constants.tsx new file mode 100644 index 00000000..6c303105 --- /dev/null +++ b/packages/frontend/src/components/shared/Sidebar/constants.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { FolderIcon, SettingsSlidersIcon } from 'components/shared/CustomIcon'; + +export const SIDEBAR_MENU = (orgSlug?: string) => [ + { + title: 'Projects', + url: `/${orgSlug}/`, + icon: , + }, + { + title: 'Settings', + url: `/${orgSlug}/settings`, + icon: , + }, +]; diff --git a/packages/frontend/src/layouts/ProjectSearch.tsx b/packages/frontend/src/layouts/ProjectSearch.tsx index 5805ba37..5a71356d 100644 --- a/packages/frontend/src/layouts/ProjectSearch.tsx +++ b/packages/frontend/src/layouts/ProjectSearch.tsx @@ -2,14 +2,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; import { User } from 'gql-client'; -// import { Tooltip } from '@material-tailwind/react'; - -import HorizontalLine from '../components/HorizontalLine'; -import ProjectSearchBar from '../components/projects/ProjectSearchBar'; -import { useGQLClient } from '../context/GQLClientContext'; -import { formatAddress } from '../utils/format'; +import HorizontalLine from 'components/HorizontalLine'; +import { useGQLClient } from 'context/GQLClientContext'; import { NotificationBellIcon, PlusIcon } from 'components/shared/CustomIcon'; import { Button } from 'components/shared/Button'; +import { Avatar } from 'components/shared/Avatar'; +import { getInitials } from 'utils/geInitials'; +import { formatAddress } from 'utils/format'; +import { ProjectSearchBar } from 'components/projects/ProjectSearchBar'; const ProjectSearch = () => { const navigate = useNavigate(); @@ -32,10 +32,11 @@ const ProjectSearch = () => { }, []); return ( -
-
-
-
+
+ {/* Header */} +
+
+
{ navigate( @@ -44,30 +45,37 @@ const ProjectSearch = () => { }} />
- - - {user?.name ? ( - - ) : null} +
+ + + {user?.name && ( + + )} +
-
+ + {/* Content */} +
-
-
+ + ); }; diff --git a/packages/frontend/src/pages/org-slug/index.tsx b/packages/frontend/src/pages/org-slug/index.tsx index 036193cf..e0e5af06 100644 --- a/packages/frontend/src/pages/org-slug/index.tsx +++ b/packages/frontend/src/pages/org-slug/index.tsx @@ -26,7 +26,7 @@ const Projects = () => { }, [orgSlug]); return ( -
+
{/* Header */}
@@ -44,7 +44,7 @@ const Projects = () => {
{/* List of projects */} -
+
{projects.length > 0 && projects.map((project, key) => { return ; diff --git a/packages/frontend/src/pages/org-slug/layout.tsx b/packages/frontend/src/pages/org-slug/layout.tsx index c1be6b8c..62ec1aa1 100644 --- a/packages/frontend/src/pages/org-slug/layout.tsx +++ b/packages/frontend/src/pages/org-slug/layout.tsx @@ -1,31 +1,130 @@ +import { Logo } from 'components/Logo'; +import { Button } from 'components/shared/Button'; +import { + CrossIcon, + MenuIcon, + NotificationBellIcon, + SearchIcon, +} from 'components/shared/CustomIcon'; import { Sidebar } from 'components/shared/Sidebar'; import { OctokitProvider } from 'context/OctokitContext'; -import React, { ComponentPropsWithoutRef } from 'react'; -import { Outlet } from 'react-router-dom'; +import React, { ComponentPropsWithoutRef, useEffect, useState } from 'react'; +import { Outlet, useParams } from 'react-router-dom'; +import { AnimatePresence, motion } from 'framer-motion'; import { cn } from 'utils/classnames'; +import { useMediaQuery } from 'usehooks-ts'; +import { ProjectSearchBarDialog } from 'components/projects/ProjectSearchBar'; export interface DashboardLayoutProps extends ComponentPropsWithoutRef<'section'> {} export const DashboardLayout = ({ className, - children, ...props }: DashboardLayoutProps) => { + const { orgSlug } = useParams(); + const isDesktop = useMediaQuery('(min-width: 960px)'); + + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + + useEffect(() => { + if (isDesktop) { + setIsSidebarOpen(false); + } + }, [isDesktop]); + return ( -
- -
-
- - - + <> +
+ {/* Header on mobile */} +
+ +
+ + {isSidebarOpen ? ( + + + + ) : ( + + <> + + + + + + )} + +
-
- {children} -
+
+ + +
+ + + +
+
+
+
+ setIsSearchOpen(false)} + onClose={() => setIsSearchOpen(false)} + /> + ); }; diff --git a/yarn.lock b/yarn.lock index 40284615..f0ed8b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10172,6 +10172,15 @@ framer-motion@6.5.1: optionalDependencies: "@emotion/is-prop-valid" "^0.8.2" +framer-motion@^11.0.8: + version "11.0.8" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.0.8.tgz#8f97a18cbad5858d85b53bc325c40a03d0a5c203" + integrity sha512-1KSGNuqe1qZkS/SWQlDnqK2VCVzRVEoval379j0FiUBJAZoqgwyvqFkfvJbgW2IPFo4wX16K+M0k5jO23lCIjA== + dependencies: + tslib "^2.4.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + framesync@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20"