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:
Wahyu Kurniawan 2024-03-07 12:52:12 +07:00 committed by GitHub
commit 3fdc0b2dff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 666 additions and 241 deletions

View File

@ -31,6 +31,7 @@
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"downshift": "^8.3.2", "downshift": "^8.3.2",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"framer-motion": "^11.0.8",
"gql-client": "^1.0.0", "gql-client": "^1.0.0",
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",

View 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>
);
};

View File

@ -15,7 +15,8 @@ const SearchBar: React.ForwardRefRenderFunction<
value={value} value={value}
type="search" type="search"
placeholder={placeholder} placeholder={placeholder}
appearance={'borderless'} appearance="borderless"
className="w-full lg:w-[459px]"
{...props} {...props}
/> />
</div> </div>

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 };

View File

@ -0,0 +1,2 @@
export * from './ProjectSearchBar';
export * from './ProjectSearchBarDialog';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -4,17 +4,17 @@ import { CustomIcon, CustomIconProps } from './CustomIcon';
export const SearchIcon = (props: CustomIconProps) => { export const SearchIcon = (props: CustomIconProps) => {
return ( return (
<CustomIcon <CustomIcon
width="24" width="18"
height="24" height="18"
viewBox="0 0 24 24" viewBox="0 0 18 18"
fill="none" fill="none"
{...props} {...props}
> >
<path <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" stroke="currentColor"
strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
/> />
</CustomIcon> </CustomIcon>
); );

View File

@ -55,6 +55,8 @@ export * from './UndoIcon';
export * from './LoaderIcon'; export * from './LoaderIcon';
export * from './MinusCircleIcon'; export * from './MinusCircleIcon';
export * from './CopyIcon'; export * from './CopyIcon';
export * from './MenuIcon';
export * from './LogoutIcon';
export * from './CirclePlaceholderOnIcon'; export * from './CirclePlaceholderOnIcon';
export * from './WarningTriangleIcon'; export * from './WarningTriangleIcon';
export * from './CheckRadioOutlineIcon'; export * from './CheckRadioOutlineIcon';

View File

@ -1,26 +1,49 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, NavLink, useNavigate, useParams } from 'react-router-dom'; import { NavLink, useNavigate, useParams } from 'react-router-dom';
import { Organization } from 'gql-client'; import { Organization, User } from 'gql-client';
import { motion } from 'framer-motion';
import { useDisconnect } from 'wagmi'; import { useDisconnect } from 'wagmi';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from 'context/GQLClientContext';
import { import {
FolderIcon,
GlobeIcon, GlobeIcon,
LifeBuoyIcon, LifeBuoyIcon,
LogoutIcon,
QuestionMarkRoundIcon, QuestionMarkRoundIcon,
SettingsSlidersIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Tabs } from 'components/shared/Tabs'; 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'; import { UserSelect } from 'components/shared/UserSelect';
export const Sidebar = () => { interface SidebarProps {
mobileOpen?: boolean;
}
export const Sidebar = ({ mobileOpen }: SidebarProps) => {
const { orgSlug } = useParams(); const { orgSlug } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const client = useGQLClient(); const client = useGQLClient();
const { disconnect } = useDisconnect(); 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 [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
@ -43,6 +66,7 @@ export const Sidebar = () => {
imgSrc: '/logo.svg', imgSrc: '/logo.svg',
}; };
}, [organizations, selectedOrgSlug, orgSlug]); }, [organizations, selectedOrgSlug, orgSlug]);
const formattedSelectOptions = useMemo(() => { const formattedSelectOptions = useMemo(() => {
return organizations.map((org) => ({ return organizations.map((org) => ({
value: org.slug, value: org.slug,
@ -51,68 +75,100 @@ export const Sidebar = () => {
})); }));
}, [organizations, selectedOrgSlug, orgSlug]); }, [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(() => { const handleLogOut = useCallback(() => {
disconnect(); disconnect();
navigate('/login'); navigate('/login');
}, [disconnect, navigate]); }, [disconnect, navigate]);
return ( return (
<nav className="flex flex-col h-full px-6 py-8 gap-9"> <motion.nav
{/* Logo */} initial={{ x: -320 }}
<Link to={`/${orgSlug}`}> animate={{ x: isDesktop || mobileOpen ? 0 : -320 }}
<div className="flex items-center gap-3 px-2"> exit={{ x: -320 }}
<img transition={{ ease: 'easeInOut', duration: 0.3 }}
src="/logo.svg" className={cn(
alt="Snowball Logo" 'h-full flex-none w-[320px] flex flex-col overflow-y-auto',
className="h-10 w-10 rounded-lg" {
/> flex: mobileOpen,
<Heading className="text-[24px] font-semibold">Snowball</Heading> },
)}
>
<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 */}
<div className="hidden lg:flex">
<Logo orgSlug={orgSlug} />
</div>
{/* Switch organization */}
<div className="flex flex-1 flex-col gap-4">
<UserSelect
value={formattedSelected}
options={formattedSelectOptions}
/>
<Tabs defaultValue="Projects" orientation="vertical">
<Tabs.List>{renderMenu}</Tabs.List>
</Tabs>
</div>
{/* Bottom navigation */}
<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=""
className="hidden lg:flex"
>
<a className="cursor-pointer" onClick={handleLogOut}>
Log Out
</a>
</Tabs.Trigger>
<Tabs.Trigger icon={<QuestionMarkRoundIcon />} value="">
<a className="cursor-pointer">Documentation</a>
</Tabs.Trigger>
<Tabs.Trigger icon={<LifeBuoyIcon />} value="">
<a className="cursor-pointer">Support</a>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</div> </div>
</Link>
{/* Switch organization */}
<div className="flex flex-1 flex-col gap-4">
<UserSelect
value={formattedSelected}
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>
</div> </div>
{/* Bottom navigation */} {/* Only shows when on mobile */}
<div className="flex flex-col justify-end"> <div className="shadow-card-sm py-4 pl-4 pr-2 flex lg:hidden items-center border-t border-border-separator/[0.06]">
<Tabs defaultValue="Projects" orientation="vertical"> {user?.name && (
{/* // TODO: use proper link buttons */} <div className="flex items-center flex-1 gap-3">
<Tabs.List> <Avatar
<Tabs.Trigger icon={<GlobeIcon />} value=""> fallbackProps={{ className: 'bg-base-bg-alternate' }}
<a className="cursor-pointer" onClick={handleLogOut}> size={44}
Log Out initials={getInitials(formatAddress(user.name))}
</a> />
</Tabs.Trigger> <p className="text-sm tracking-[-0.006em] text-elements-high-em">
<Tabs.Trigger icon={<QuestionMarkRoundIcon />} value=""> {formatAddress(user.name)}
<a className="cursor-pointer">Documentation</a> </p>
</Tabs.Trigger> </div>
<Tabs.Trigger icon={<LifeBuoyIcon />} value=""> )}
<a className="cursor-pointer">Support</a> <Button
</Tabs.Trigger> iconOnly
</Tabs.List> variant="ghost"
</Tabs> className="text-elements-low-em"
onClick={handleLogOut}
>
<LogoutIcon />
</Button>
</div> </div>
</nav> </motion.nav>
); );
}; };

View File

@ -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 />,
},
];

View File

@ -2,14 +2,14 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { User } from 'gql-client'; import { User } from 'gql-client';
// import { Tooltip } from '@material-tailwind/react'; import HorizontalLine from 'components/HorizontalLine';
import { useGQLClient } from 'context/GQLClientContext';
import HorizontalLine from '../components/HorizontalLine';
import ProjectSearchBar from '../components/projects/ProjectSearchBar';
import { useGQLClient } from '../context/GQLClientContext';
import { formatAddress } from '../utils/format';
import { NotificationBellIcon, PlusIcon } from 'components/shared/CustomIcon'; import { NotificationBellIcon, PlusIcon } from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button'; 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 ProjectSearch = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -32,10 +32,11 @@ const ProjectSearch = () => {
}, []); }, []);
return ( return (
<div className="h-full"> <section className="h-full flex flex-col">
<div className="sticky top-0 bg-white z-30"> {/* Header */}
<div className="flex pl-3 pr-8 pt-3 pb-3 items-center"> <div className="sticky hidden lg:block top-0 border-b bg-base-bg border-border-separator/[0.06] z-30">
<div className="grow"> <div className="flex pr-6 pl-2 py-2 items-center">
<div className="flex-1">
<ProjectSearchBar <ProjectSearchBar
onChange={(project) => { onChange={(project) => {
navigate( navigate(
@ -44,30 +45,37 @@ const ProjectSearch = () => {
}} }}
/> />
</div> </div>
<Button <div className="flex items-center gap-3">
variant={'secondary'} <Button
iconOnly variant="secondary"
onClick={() => { iconOnly
fetchOrgSlug().then((organizationSlug) => { onClick={() => {
navigate(`/${organizationSlug}/projects/create`); fetchOrgSlug().then((organizationSlug) => {
}); navigate(`/${organizationSlug}/projects/create`);
}} });
> }}
<PlusIcon /> >
</Button> <PlusIcon />
<Button variant={'ghost'} iconOnly> </Button>
<NotificationBellIcon /> <Button variant="ghost" iconOnly>
</Button> <NotificationBellIcon />
{user?.name ? ( </Button>
<Button variant={'tertiary'}>{formatAddress(user.name)}</Button> {user?.name && (
) : null} <Avatar
size={44}
initials={getInitials(formatAddress(user.name))}
/>
)}
</div>
</div> </div>
<HorizontalLine /> <HorizontalLine />
</div> </div>
<div className="z-0 h-full">
{/* Content */}
<section className="h-full z-0">
<Outlet /> <Outlet />
</div> </section>
</div> </section>
); );
}; };

View File

@ -26,7 +26,7 @@ const Projects = () => {
}, [orgSlug]); }, [orgSlug]);
return ( 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 */} {/* Header */}
<div className="flex items-center"> <div className="flex items-center">
<div className="grow"> <div className="grow">
@ -44,7 +44,7 @@ const Projects = () => {
</Link> </Link>
</div> </div>
{/* List of projects */} {/* 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.length > 0 &&
projects.map((project, key) => { projects.map((project, key) => {
return <ProjectCard project={project} key={key} />; return <ProjectCard project={project} key={key} />;

View File

@ -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 { Sidebar } from 'components/shared/Sidebar';
import { OctokitProvider } from 'context/OctokitContext'; import { OctokitProvider } from 'context/OctokitContext';
import React, { ComponentPropsWithoutRef } from 'react'; import React, { ComponentPropsWithoutRef, useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet, useParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
import { useMediaQuery } from 'usehooks-ts';
import { ProjectSearchBarDialog } from 'components/projects/ProjectSearchBar';
export interface DashboardLayoutProps export interface DashboardLayoutProps
extends ComponentPropsWithoutRef<'section'> {} extends ComponentPropsWithoutRef<'section'> {}
export const DashboardLayout = ({ export const DashboardLayout = ({
className, className,
children,
...props ...props
}: DashboardLayoutProps) => { }: 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 ( return (
<section <>
{...props} <section
className={cn('grid grid-cols-5 h-screen bg-snowball-50', className)} {...props}
> className={cn(
<Sidebar /> 'flex flex-col lg:flex-row h-screen bg-snowball-50',
<div className="col-span-4 h-full px-3 py-3 overflow-y-hidden"> className,
<div className="rounded-3xl bg-base-bg h-full shadow-card overflow-y-auto relative"> )}
<OctokitProvider> >
<Outlet /> {/* Header on mobile */}
</OctokitProvider> <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>
</div> <div className="flex h-full w-full overflow-hidden">
{children} <Sidebar mobileOpen={isSidebarOpen} />
</section> <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>
</section>
<ProjectSearchBarDialog
open={isSearchOpen}
onClickItem={() => setIsSearchOpen(false)}
onClose={() => setIsSearchOpen(false)}
/>
</>
); );
}; };

View File

@ -10172,6 +10172,15 @@ framer-motion@6.5.1:
optionalDependencies: optionalDependencies:
"@emotion/is-prop-valid" "^0.8.2" "@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: framesync@6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20"