Merge pull request #156 from snowball-tools/ayungavis/T-4931-layout-mobile-sidebar-layout
[T-4931: style] Mobile sidebar layout and search bar
This commit is contained in:
commit
3fdc0b2dff
@ -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",
|
||||
|
24
packages/frontend/src/components/Logo.tsx
Normal file
24
packages/frontend/src/components/Logo.tsx
Normal file
@ -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 (
|
||||
<Link to={`/${orgSlug}`}>
|
||||
<div className="flex items-center gap-3 px-0 lg:px-2">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Snowball Logo"
|
||||
className="lg:h-10 lg:w-10 h-8 w-8 rounded-lg"
|
||||
/>
|
||||
<Heading className="lg:text-[24px] text-[19px] font-semibold">
|
||||
Snowball
|
||||
</Heading>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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<Project[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<Project | null>(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<string>(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 (
|
||||
<div className="relative">
|
||||
<SearchBar {...getInputProps()} />
|
||||
<Card
|
||||
className={`absolute w-1/2 max-h-52 -mt-1 overflow-y-auto ${
|
||||
(!inputValue || !isOpen) && 'hidden'
|
||||
}`}
|
||||
placeholder={''}
|
||||
>
|
||||
<List {...getMenuProps()}>
|
||||
{items.length ? (
|
||||
<>
|
||||
<div className="p-3">
|
||||
<Typography variant="small" color="gray" placeholder={''}>
|
||||
Suggestions
|
||||
</Typography>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<ListItem
|
||||
selected={highlightedIndex === index || selectedItem === item}
|
||||
key={item.id}
|
||||
placeholder={''}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<ListItemPrefix placeholder={''}>
|
||||
<Avatar
|
||||
src={item.icon || '/gray.png'}
|
||||
variant="rounded"
|
||||
placeholder={''}
|
||||
/>
|
||||
</ListItemPrefix>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" placeholder={''}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="font-normal"
|
||||
placeholder={''}
|
||||
>
|
||||
{item.organization.name}
|
||||
</Typography>
|
||||
</div>
|
||||
</ListItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<Typography placeholder={''}>
|
||||
^ No projects matching this name
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSearchBar;
|
@ -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<Project[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<Project | null>(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<string>(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 (
|
||||
<div className="relative w-full lg:w-fit">
|
||||
<SearchBar {...getInputProps()} />
|
||||
<div
|
||||
{...getMenuProps()}
|
||||
className={cn(
|
||||
'flex flex-col shadow-dropdown rounded-xl bg-surface-card absolute w-[459px] max-h-52 overflow-y-auto px-2 py-2 gap-1 z-50',
|
||||
{ hidden: !inputValue || !isOpen },
|
||||
)}
|
||||
>
|
||||
{items.length ? (
|
||||
<>
|
||||
<div className="px-2 py-2">
|
||||
<p className="text-elements-mid-em text-xs font-medium">
|
||||
Suggestions
|
||||
</p>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<ProjectSearchBarItem
|
||||
{...getItemProps({ item, index })}
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={highlightedIndex === index || selectedItem === item}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<ProjectSearchBarEmpty />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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<Project[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<Project | null>(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<string>(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 (
|
||||
<Dialog.Root {...props}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-base-bg fixed inset-0 md:hidden overflow-y-auto" />
|
||||
<Dialog.Content>
|
||||
<div className="h-full flex flex-col fixed top-0 inset-0">
|
||||
<div className="py-2.5 px-4 flex items-center justify-between border-b border-border-separator/[0.06]">
|
||||
<Input
|
||||
{...getInputProps()}
|
||||
leftIcon={<SearchIcon />}
|
||||
placeholder="Search"
|
||||
appearance="borderless"
|
||||
autoFocus
|
||||
/>
|
||||
<Button iconOnly variant="ghost" onClick={handleClose}>
|
||||
<CrossIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
{items.length > 0
|
||||
? items.map((item, index) => (
|
||||
<>
|
||||
<div className="px-2 py-2">
|
||||
<p className="text-elements-mid-em text-xs font-medium">
|
||||
Suggestions
|
||||
</p>
|
||||
</div>
|
||||
<ProjectSearchBarItem
|
||||
{...getItemProps({ item, index })}
|
||||
key={item.id}
|
||||
item={item}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
: inputValue && <ProjectSearchBarEmpty />}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex items-center px-2 py-2 gap-3', className)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-orange-50 text-elements-warning">
|
||||
<InfoRoundFilledIcon size={16} />
|
||||
</div>
|
||||
<p className="text-elements-low-em text-sm tracking-[-0.006em]">
|
||||
No projects matching this name
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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<UseComboboxGetItemPropsReturnValue, Project[]>,
|
||||
'index' | 'item'
|
||||
>
|
||||
>;
|
||||
|
||||
interface ProjectSearchBarItemProps extends MergedComponentPropsWithoutRef {
|
||||
item: Project;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const ProjectSearchBarItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ProjectSearchBarItemProps
|
||||
>(({ item, active, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
ref={ref}
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'px-2 py-2 flex items-center gap-3 rounded-lg text-left hover:bg-base-bg-emphasized',
|
||||
{
|
||||
'bg-base-bg-emphasized': active,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
imageSrc={item.icon}
|
||||
initials={getInitials(item.name)}
|
||||
/>
|
||||
<div className="flex flex-col flex-1">
|
||||
<p className="text-sm tracking-[-0.006em] text-elements-high-em">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-elements-low-em">{item.organization.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
ProjectSearchBarItem.displayName = 'ProjectSearchBarItem';
|
||||
|
||||
export { ProjectSearchBarItem };
|
@ -0,0 +1,2 @@
|
||||
export * from './ProjectSearchBar';
|
||||
export * from './ProjectSearchBarDialog';
|
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||
|
||||
export const LogoutIcon: React.FC<CustomIconProps> = (props) => {
|
||||
return (
|
||||
<CustomIcon
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.3125 3.375C3.79473 3.375 3.375 3.79473 3.375 4.3125L3.375 13.6875C3.375 14.2053 3.79473 14.625 4.3125 14.625H8.4375C8.74816 14.625 9 14.8768 9 15.1875C9 15.4982 8.74816 15.75 8.4375 15.75H4.3125C3.17341 15.75 2.25 14.8266 2.25 13.6875L2.25 4.3125C2.25 3.17341 3.17341 2.25 4.3125 2.25L8.4375 2.25C8.74816 2.25 9 2.50184 9 2.8125C9 3.12316 8.74816 3.375 8.4375 3.375L4.3125 3.375ZM11.4148 5.22725C11.6344 5.00758 11.9906 5.00758 12.2102 5.22725L15.5852 8.60224C15.8049 8.82191 15.8049 9.17807 15.5852 9.39774L12.2102 12.7727C11.9906 12.9924 11.6344 12.9924 11.4148 12.7727C11.1951 12.5531 11.1951 12.1969 11.4148 11.9773L13.8295 9.56249L6.75 9.56249C6.43934 9.56249 6.1875 9.31065 6.1875 8.99999C6.1875 8.68933 6.43934 8.43749 6.75 8.43749L13.8295 8.43749L11.4148 6.02275C11.1951 5.80308 11.1951 5.44692 11.4148 5.22725Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</CustomIcon>
|
||||
);
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||
|
||||
export const MenuIcon = (props: CustomIconProps) => {
|
||||
return (
|
||||
<CustomIcon
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2 4.5C2 4.22386 2.22386 4 2.5 4H18.5C18.7761 4 19 4.22386 19 4.5C19 4.77614 18.7761 5 18.5 5H2.5C2.22386 5 2 4.77614 2 4.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2 10.5C2 10.2239 2.22386 10 2.5 10H10.5C10.7761 10 11 10.2239 11 10.5C11 10.7761 10.7761 11 10.5 11H2.5C2.22386 11 2 10.7761 2 10.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2 16.5C2 16.2239 2.22386 16 2.5 16H18.5C18.7761 16 19 16.2239 19 16.5C19 16.7761 18.7761 17 18.5 17H2.5C2.22386 17 2 16.7761 2 16.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</CustomIcon>
|
||||
);
|
||||
};
|
@ -4,17 +4,17 @@ import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||
export const SearchIcon = (props: CustomIconProps) => {
|
||||
return (
|
||||
<CustomIcon
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M20 20L16.05 16.05M18 11C18 14.866 14.866 18 11 18C7.13401 18 4 14.866 4 11C4 7.13401 7.13401 4 11 4C14.866 4 18 7.13401 18 11Z"
|
||||
d="M15.375 15.375L12.225 12.225M13.875 8.25C13.875 11.3566 11.3566 13.875 8.25 13.875C5.1434 13.875 2.625 11.3566 2.625 8.25C2.625 5.1434 5.1434 2.625 8.25 2.625C11.3566 2.625 13.875 5.1434 13.875 8.25Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</CustomIcon>
|
||||
);
|
||||
|
@ -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';
|
||||
|
@ -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<User>();
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
const { user } = await client.getUser();
|
||||
setUser(user);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
@ -43,6 +66,7 @@ export const Sidebar = () => {
|
||||
imgSrc: '/logo.svg',
|
||||
};
|
||||
}, [organizations, selectedOrgSlug, orgSlug]);
|
||||
|
||||
const formattedSelectOptions = useMemo(() => {
|
||||
return organizations.map((org) => ({
|
||||
value: org.slug,
|
||||
@ -51,24 +75,43 @@ export const Sidebar = () => {
|
||||
}));
|
||||
}, [organizations, selectedOrgSlug, orgSlug]);
|
||||
|
||||
const renderMenu = useMemo(() => {
|
||||
return SIDEBAR_MENU(orgSlug).map(({ title, icon, url }, index) => (
|
||||
<NavLink to={url} key={index}>
|
||||
<Tabs.Trigger icon={icon} value={title}>
|
||||
{title}
|
||||
</Tabs.Trigger>
|
||||
</NavLink>
|
||||
));
|
||||
}, [orgSlug]);
|
||||
|
||||
const handleLogOut = useCallback(() => {
|
||||
disconnect();
|
||||
navigate('/login');
|
||||
}, [disconnect, navigate]);
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col h-full px-6 py-8 gap-9">
|
||||
<motion.nav
|
||||
initial={{ x: -320 }}
|
||||
animate={{ x: isDesktop || mobileOpen ? 0 : -320 }}
|
||||
exit={{ x: -320 }}
|
||||
transition={{ ease: 'easeInOut', duration: 0.3 }}
|
||||
className={cn(
|
||||
'h-full flex-none w-[320px] flex flex-col overflow-y-auto',
|
||||
{
|
||||
flex: mobileOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col h-full pt-5 lg:pt-8 pb-0 px-4 lg:px-6 lg:pb-8 gap-9',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link to={`/${orgSlug}`}>
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Snowball Logo"
|
||||
className="h-10 w-10 rounded-lg"
|
||||
/>
|
||||
<Heading className="text-[24px] font-semibold">Snowball</Heading>
|
||||
<div className="hidden lg:flex">
|
||||
<Logo orgSlug={orgSlug} />
|
||||
</div>
|
||||
</Link>
|
||||
{/* Switch organization */}
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<UserSelect
|
||||
@ -76,30 +119,19 @@ export const Sidebar = () => {
|
||||
options={formattedSelectOptions}
|
||||
/>
|
||||
<Tabs defaultValue="Projects" orientation="vertical">
|
||||
<Tabs.List>
|
||||
{[
|
||||
{ title: 'Projects', url: `/${orgSlug}/`, icon: <FolderIcon /> },
|
||||
{
|
||||
title: 'Settings',
|
||||
url: `/${orgSlug}/settings`,
|
||||
icon: <SettingsSlidersIcon />,
|
||||
},
|
||||
].map(({ title, icon, url }, index) => (
|
||||
<NavLink to={url} key={index}>
|
||||
<Tabs.Trigger icon={icon} value={title}>
|
||||
{title}
|
||||
</Tabs.Trigger>
|
||||
</NavLink>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs.List>{renderMenu}</Tabs.List>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/* Bottom navigation */}
|
||||
<div className="flex flex-col justify-end">
|
||||
<div className="flex flex-col gap-5 justify-end">
|
||||
<Tabs defaultValue="Projects" orientation="vertical">
|
||||
{/* // TODO: use proper link buttons */}
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger icon={<GlobeIcon />} value="">
|
||||
<Tabs.Trigger
|
||||
icon={<GlobeIcon />}
|
||||
value=""
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<a className="cursor-pointer" onClick={handleLogOut}>
|
||||
Log Out
|
||||
</a>
|
||||
@ -113,6 +145,30 @@ export const Sidebar = () => {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
{/* Only shows when on mobile */}
|
||||
<div className="shadow-card-sm py-4 pl-4 pr-2 flex lg:hidden items-center border-t border-border-separator/[0.06]">
|
||||
{user?.name && (
|
||||
<div className="flex items-center flex-1 gap-3">
|
||||
<Avatar
|
||||
fallbackProps={{ className: 'bg-base-bg-alternate' }}
|
||||
size={44}
|
||||
initials={getInitials(formatAddress(user.name))}
|
||||
/>
|
||||
<p className="text-sm tracking-[-0.006em] text-elements-high-em">
|
||||
{formatAddress(user.name)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
iconOnly
|
||||
variant="ghost"
|
||||
className="text-elements-low-em"
|
||||
onClick={handleLogOut}
|
||||
>
|
||||
<LogoutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.nav>
|
||||
);
|
||||
};
|
||||
|
@ -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: <FolderIcon />,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
url: `/${orgSlug}/settings`,
|
||||
icon: <SettingsSlidersIcon />,
|
||||
},
|
||||
];
|
@ -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 (
|
||||
<div className="h-full">
|
||||
<div className="sticky top-0 bg-white z-30">
|
||||
<div className="flex pl-3 pr-8 pt-3 pb-3 items-center">
|
||||
<div className="grow">
|
||||
<section className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="sticky hidden lg:block top-0 border-b bg-base-bg border-border-separator/[0.06] z-30">
|
||||
<div className="flex pr-6 pl-2 py-2 items-center">
|
||||
<div className="flex-1">
|
||||
<ProjectSearchBar
|
||||
onChange={(project) => {
|
||||
navigate(
|
||||
@ -44,8 +45,9 @@ const ProjectSearch = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
variant="secondary"
|
||||
iconOnly
|
||||
onClick={() => {
|
||||
fetchOrgSlug().then((organizationSlug) => {
|
||||
@ -55,19 +57,25 @@ const ProjectSearch = () => {
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Button variant={'ghost'} iconOnly>
|
||||
<Button variant="ghost" iconOnly>
|
||||
<NotificationBellIcon />
|
||||
</Button>
|
||||
{user?.name ? (
|
||||
<Button variant={'tertiary'}>{formatAddress(user.name)}</Button>
|
||||
) : null}
|
||||
{user?.name && (
|
||||
<Avatar
|
||||
size={44}
|
||||
initials={getInitials(formatAddress(user.name))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalLine />
|
||||
</div>
|
||||
<div className="z-0 h-full">
|
||||
|
||||
{/* Content */}
|
||||
<section className="h-full z-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -26,7 +26,7 @@ const Projects = () => {
|
||||
}, [orgSlug]);
|
||||
|
||||
return (
|
||||
<section className="px-6 py-6 flex flex-col gap-6">
|
||||
<section className="px-4 md:px-6 py-6 flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center">
|
||||
<div className="grow">
|
||||
@ -44,7 +44,7 @@ const Projects = () => {
|
||||
</Link>
|
||||
</div>
|
||||
{/* List of projects */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-flow-row grid-cols-[repeat(auto-fill,_minmax(280px,_1fr))] gap-4">
|
||||
{projects.length > 0 &&
|
||||
projects.map((project, key) => {
|
||||
return <ProjectCard project={project} key={key} />;
|
||||
|
@ -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 (
|
||||
<>
|
||||
<section
|
||||
{...props}
|
||||
className={cn('grid grid-cols-5 h-screen bg-snowball-50', className)}
|
||||
className={cn(
|
||||
'flex flex-col lg:flex-row h-screen bg-snowball-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
<div className="col-span-4 h-full px-3 py-3 overflow-y-hidden">
|
||||
<div className="rounded-3xl bg-base-bg h-full shadow-card overflow-y-auto relative">
|
||||
{/* Header on mobile */}
|
||||
<div className="flex lg:hidden items-center px-4 py-2.5 justify-between">
|
||||
<Logo orgSlug={orgSlug} />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AnimatePresence>
|
||||
{isSidebarOpen ? (
|
||||
<motion.div
|
||||
key="crossIcon"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: { duration: 0.2, delay: 0.3 },
|
||||
}}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
>
|
||||
<Button
|
||||
iconOnly
|
||||
variant="ghost"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
<CrossIcon size={18} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="menuIcons"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: { duration: 0.2, delay: 0.2 },
|
||||
}}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
>
|
||||
<>
|
||||
<Button iconOnly variant="ghost">
|
||||
<NotificationBellIcon size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
iconOnly
|
||||
variant="ghost"
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
>
|
||||
<SearchIcon size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
iconOnly
|
||||
variant="ghost"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
>
|
||||
<MenuIcon size={18} />
|
||||
</Button>
|
||||
</>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<Sidebar mobileOpen={isSidebarOpen} />
|
||||
<motion.div
|
||||
className={cn(
|
||||
'w-full h-full pr-1 pl-1 py-1 lg:pl-0 lg:pr-3 lg:py-3 overflow-y-hidden min-w-[320px]',
|
||||
{ 'flex-shrink-0': isSidebarOpen || !isDesktop },
|
||||
)}
|
||||
animate={{
|
||||
x: isSidebarOpen || isDesktop ? 0 : -320,
|
||||
}}
|
||||
transition={{ ease: 'easeInOut', duration: 0.3 }}
|
||||
>
|
||||
<div className="rounded-t-3xl lg:rounded-3xl bg-base-bg h-full shadow-card overflow-y-auto relative">
|
||||
<OctokitProvider>
|
||||
<Outlet />
|
||||
</OctokitProvider>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
<ProjectSearchBarDialog
|
||||
open={isSearchOpen}
|
||||
onClickItem={() => setIsSearchOpen(false)}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user