all
This commit is contained in:
@ -0,0 +1,25 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Circle } from '@chakra-ui/react'
|
||||
import { variables } from '@/lib'
|
||||
import { useAppSelector } from '@/store/hook'
|
||||
import { NavType } from '@/store/ui/nav'
|
||||
|
||||
export type AccountMenuActiveCircleProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const AccountMenuActiveCircle = ({
|
||||
children,
|
||||
}: AccountMenuActiveCircleProps) => {
|
||||
const activeNav = useAppSelector((state) => state.ui.nav.active)
|
||||
return (
|
||||
<Circle
|
||||
size="50px"
|
||||
bg={activeNav === NavType.Account ? variables.gradiant : 'transparent'}
|
||||
>
|
||||
{children}
|
||||
</Circle>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountMenuActiveCircle
|
@ -0,0 +1,36 @@
|
||||
import { Avatar } from '@chakra-ui/react'
|
||||
import { forwardRef } from '@chakra-ui/system'
|
||||
import cx from 'classnames'
|
||||
import { User } from '@/client/idp/user'
|
||||
import { useAppSelector } from '@/store/hook'
|
||||
import { NavType } from '@/store/ui/nav'
|
||||
import AccountMenuActiveCircle from './account-menu-active-circle'
|
||||
|
||||
export type AccountMenuAvatarButtonProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
const AccountMenuAvatarButton = forwardRef<AccountMenuAvatarButtonProps, 'div'>(
|
||||
({ user, ...props }, ref) => {
|
||||
const activeNav = useAppSelector((state) => state.ui.nav.active)
|
||||
const isActive = activeNav === NavType.Account
|
||||
return (
|
||||
<div ref={ref} {...props} className={cx('cursor-pointer')}>
|
||||
<AccountMenuActiveCircle>
|
||||
<Avatar
|
||||
name={user.fullName}
|
||||
src={user.picture}
|
||||
size="sm"
|
||||
className={cx('w-[40px]', 'h-[40px]', {
|
||||
'border': isActive,
|
||||
'border-gray-300': isActive,
|
||||
'dark:border-gray-700': isActive,
|
||||
})}
|
||||
/>
|
||||
</AccountMenuActiveCircle>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default AccountMenuAvatarButton
|
@ -0,0 +1,24 @@
|
||||
import { Avatar } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { User } from '@/client/idp/user'
|
||||
|
||||
export type AccountMenuAvatarImageProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
const AccountMenuAvatarImage = ({ user }: AccountMenuAvatarImageProps) => (
|
||||
<Avatar
|
||||
name={user.fullName}
|
||||
src={user.picture}
|
||||
size="sm"
|
||||
className={cx(
|
||||
'w-[40px]',
|
||||
'h-[40px]',
|
||||
'border',
|
||||
'border-gray-300',
|
||||
'dark:border-gray-700',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
export default AccountMenuAvatarImage
|
@ -0,0 +1,71 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Portal,
|
||||
SkeletonCircle,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import UserAPI from '@/client/idp/user'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import AccountMenuActiveCircle from './account-menu-active-circle'
|
||||
import AccountMenuAvatarButton from './account-menu-avatar-button'
|
||||
import AccountMenuAvatarImage from './account-menu-avatar-image'
|
||||
|
||||
const TopBarAccountMenu = () => {
|
||||
const { data: user } = UserAPI.useGet(swrConfig())
|
||||
if (user) {
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton as={AccountMenuAvatarButton} user={user} />
|
||||
<Portal>
|
||||
<MenuList>
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-0.5',
|
||||
'px-1',
|
||||
)}
|
||||
>
|
||||
<AccountMenuAvatarImage user={user} />
|
||||
<div className={cx('flex', 'flex-col', 'gap-0')}>
|
||||
<span
|
||||
className={cx(
|
||||
'font-semibold',
|
||||
'grow',
|
||||
'text-ellipsis',
|
||||
'overflow-hidden',
|
||||
'whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
{user.fullName}
|
||||
</span>
|
||||
<span className={cx('text-gray-500')}>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<MenuDivider />
|
||||
<MenuItem as={Link} to="/account/settings">
|
||||
Account
|
||||
</MenuItem>
|
||||
<MenuItem as={Link} to="/sign-out" className={cx('text-red-500')}>
|
||||
Sign Out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<AccountMenuActiveCircle>
|
||||
<SkeletonCircle size="40px" />
|
||||
</AccountMenuActiveCircle>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default TopBarAccountMenu
|
65
Downloads/Voltaserve/ui/src/components/top-bar/index.tsx
Normal file
65
Downloads/Voltaserve/ui/src/components/top-bar/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import cx from 'classnames'
|
||||
import TopBarAccountMenu from '@/components/top-bar/account-menu'
|
||||
import TopBarNotificationDrawer from '@/components/top-bar/notification-drawer'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hook'
|
||||
import { activeNavChanged, NavType } from '@/store/ui/nav'
|
||||
import {
|
||||
CreateGroupButton,
|
||||
CreateOrganizationButton,
|
||||
CreateWorkspaceButton,
|
||||
} from './top-bar-buttons'
|
||||
import TopBarSearch from './top-bar-search'
|
||||
import TopBarUploadDrawer from './top-bar-upload-drawer'
|
||||
|
||||
const TopBar = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const location = useLocation()
|
||||
const activeNav = useAppSelector((state) => state.ui.nav.active)
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/account')) {
|
||||
dispatch(activeNavChanged(NavType.Account))
|
||||
}
|
||||
if (location.pathname.startsWith('/organization')) {
|
||||
dispatch(activeNavChanged(NavType.Organizations))
|
||||
}
|
||||
if (location.pathname.startsWith('/group')) {
|
||||
dispatch(activeNavChanged(NavType.Groups))
|
||||
}
|
||||
if (location.pathname.startsWith('/workspace')) {
|
||||
dispatch(activeNavChanged(NavType.Workspaces))
|
||||
}
|
||||
}, [location, dispatch])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
'shrink-0',
|
||||
'py-0',
|
||||
'px-3',
|
||||
'w-full',
|
||||
'h-[80px]',
|
||||
)}
|
||||
>
|
||||
<div className={cx('grow')}>
|
||||
<TopBarSearch />
|
||||
</div>
|
||||
<div className={cx('flex', 'flex-row', 'items-center', 'gap-1.5')}>
|
||||
{activeNav === NavType.Workspaces && <CreateWorkspaceButton />}
|
||||
{activeNav === NavType.Groups && <CreateGroupButton />}
|
||||
{activeNav === NavType.Organizations && <CreateOrganizationButton />}
|
||||
<TopBarUploadDrawer />
|
||||
<TopBarNotificationDrawer />
|
||||
<TopBarAccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBar
|
@ -0,0 +1,71 @@
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
Divider,
|
||||
Drawer as ChakraDrawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
IconButton,
|
||||
useDisclosure,
|
||||
Circle,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import NotificationAPI from '@/client/api/notification'
|
||||
import { swrConfig } from '@/client/options'
|
||||
import { IconNotifications } from '@/lib'
|
||||
import NotificationDrawerItem from './notification-drawer-item'
|
||||
|
||||
const TopBarNotificationDrawer = () => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { data: notfications } = NotificationAPI.useGetAll(swrConfig())
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('flex', 'items-center', 'justify-center', 'relative')}>
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
icon={<IconNotifications />}
|
||||
aria-label=""
|
||||
onClick={onOpen}
|
||||
/>
|
||||
{notfications && notfications.length > 0 && (
|
||||
<Circle size="15px" bg="red" position="absolute" top={0} right={0} />
|
||||
)}
|
||||
</div>
|
||||
<ChakraDrawer
|
||||
isOpen={isOpen}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
finalFocusRef={buttonRef}
|
||||
>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>Notifications</DrawerHeader>
|
||||
<DrawerBody>
|
||||
{notfications && notfications.length > 0 ? (
|
||||
<div className={cx('flex', 'flex-col', 'gap-1.5')}>
|
||||
{notfications.map((n, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cx('flex', 'flex-col', 'gap-1.5')}
|
||||
>
|
||||
<NotificationDrawerItem notification={n} />
|
||||
{index !== notfications.length - 1 && <Divider />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>There are no notifications.</span>
|
||||
)}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</ChakraDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBarNotificationDrawer
|
@ -0,0 +1,19 @@
|
||||
import { Invitation } from '@/client/api/invitation'
|
||||
import { Notification } from '@/client/api/notification'
|
||||
import NotificationDrawerNewInvitationItem from './notification-drawer-new-Invitation-item'
|
||||
|
||||
export type NotificationDrawerItemProps = {
|
||||
notification: Notification
|
||||
}
|
||||
|
||||
const NotificationDrawerItem = ({
|
||||
notification,
|
||||
}: NotificationDrawerItemProps) => {
|
||||
if (notification.type === 'new_invitation') {
|
||||
const body: Invitation = notification.body as Invitation
|
||||
return <NotificationDrawerNewInvitationItem invitation={body} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default NotificationDrawerItem
|
@ -0,0 +1,96 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Button, useToast } from '@chakra-ui/react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import cx from 'classnames'
|
||||
import InvitationAPI, { Invitation } from '@/client/api/invitation'
|
||||
import userToString from '@/helpers/user-to-string'
|
||||
|
||||
export type NewInvitationProps = {
|
||||
invitation: Invitation
|
||||
}
|
||||
|
||||
const NotificationDrawerNewInvitationItem = ({
|
||||
invitation,
|
||||
}: NewInvitationProps) => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const toast = useToast()
|
||||
const [isAcceptLoading, setIsAcceptLoading] = useState(false)
|
||||
const [isDeclineLoading, setIsDeclineLoading] = useState(false)
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (invitationId: string) => {
|
||||
try {
|
||||
setIsAcceptLoading(true)
|
||||
await InvitationAPI.accept(invitationId)
|
||||
mutate('/notifications')
|
||||
mutate('/invitations/get_incoming')
|
||||
mutate('/organizations')
|
||||
toast({
|
||||
title: 'Invitation accepted',
|
||||
status: 'success',
|
||||
isClosable: true,
|
||||
})
|
||||
} finally {
|
||||
setIsAcceptLoading(false)
|
||||
}
|
||||
},
|
||||
[mutate, toast],
|
||||
)
|
||||
|
||||
const handleDecline = useCallback(
|
||||
async (invitationId: string) => {
|
||||
try {
|
||||
setIsDeclineLoading(true)
|
||||
await InvitationAPI.decline(invitationId)
|
||||
mutate('/notifications')
|
||||
mutate('/invitations/get_incoming')
|
||||
toast({
|
||||
title: 'Invitation declined',
|
||||
status: 'info',
|
||||
isClosable: true,
|
||||
})
|
||||
} finally {
|
||||
setIsDeclineLoading(false)
|
||||
}
|
||||
},
|
||||
[mutate, toast],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cx('flex', 'flex-col', 'gap-0.5')}>
|
||||
<div>
|
||||
You have been invited by{' '}
|
||||
<span className={cx('font-bold')}>
|
||||
{userToString(invitation.owner)}
|
||||
</span>{' '}
|
||||
to join the organization{' '}
|
||||
<span className={cx('font-bold')}>{invitation.organization.name}</span>
|
||||
.<br />
|
||||
</div>
|
||||
<div className={cx('flex', 'flex-row', 'gap-0.5', 'justify-end')}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
disabled={isAcceptLoading || isDeclineLoading}
|
||||
isLoading={isAcceptLoading}
|
||||
onClick={() => handleAccept(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
disabled={isDeclineLoading || isAcceptLoading}
|
||||
isLoading={isDeclineLoading}
|
||||
onClick={() => handleDecline(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationDrawerNewInvitationItem
|
@ -0,0 +1,39 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@chakra-ui/react'
|
||||
import { IconAdd } from '@/lib'
|
||||
|
||||
export const CreateGroupButton = () => (
|
||||
<Button
|
||||
as={Link}
|
||||
to="/new/group"
|
||||
leftIcon={<IconAdd />}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const CreateOrganizationButton = () => (
|
||||
<Button
|
||||
as={Link}
|
||||
to="/new/organization"
|
||||
leftIcon={<IconAdd />}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
>
|
||||
New Organization
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const CreateWorkspaceButton = () => (
|
||||
<Button
|
||||
as={Link}
|
||||
to="/new/workspace"
|
||||
leftIcon={<IconAdd />}
|
||||
variant="solid"
|
||||
colorScheme="blue"
|
||||
>
|
||||
New Workspace
|
||||
</Button>
|
||||
)
|
@ -0,0 +1,40 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link as ChakraLink } from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
|
||||
export type TopBarItemProps = {
|
||||
title: string
|
||||
href: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const TopBarItem = ({ title, href, isActive }: TopBarItemProps) => (
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
to={href}
|
||||
lineHeight="40px"
|
||||
variant="no-underline"
|
||||
className={cx(
|
||||
'opacity-100',
|
||||
'hover:opacity-80',
|
||||
'h-[40px]',
|
||||
'rounded-xl',
|
||||
'pt-0',
|
||||
'pr-[20px]',
|
||||
'pb-0',
|
||||
'pl-[20px]',
|
||||
'font-semibold',
|
||||
{
|
||||
'text-white': isActive,
|
||||
'dark:text-gray-600': isActive,
|
||||
'bg-black': isActive,
|
||||
'dark:bg-white': isActive,
|
||||
'bg-transparent': !isActive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</ChakraLink>
|
||||
)
|
||||
|
||||
export default TopBarItem
|
@ -0,0 +1,223 @@
|
||||
import { ChangeEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import { decodeQuery, encodeQuery } from '@/helpers/query'
|
||||
import { IconClose, IconSearch } from '@/lib'
|
||||
|
||||
const TopBarSearch = () => {
|
||||
const navigation = useNavigate()
|
||||
const location = useLocation()
|
||||
const { id, fileId } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const query = decodeQuery(searchParams.get('q') as string)
|
||||
const isWorkspaces = useMemo(
|
||||
() => location.pathname === '/workspace',
|
||||
[location],
|
||||
)
|
||||
const isFiles = useMemo(
|
||||
() =>
|
||||
location.pathname.includes('/workspace/') &&
|
||||
location.pathname.includes('/file/'),
|
||||
[location],
|
||||
)
|
||||
const isGroups = useMemo(() => location.pathname === '/group', [location])
|
||||
const isOrgs = useMemo(
|
||||
() => location.pathname === '/organization',
|
||||
[location],
|
||||
)
|
||||
const isOrgMembers = useMemo(
|
||||
() =>
|
||||
location.pathname.includes('/organization/') &&
|
||||
location.pathname.includes('/member'),
|
||||
[location],
|
||||
)
|
||||
const isGroupMembers = useMemo(
|
||||
() =>
|
||||
location.pathname.includes('/group/') &&
|
||||
location.pathname.includes('/member'),
|
||||
[location],
|
||||
)
|
||||
const isAvailable = useMemo(
|
||||
() =>
|
||||
isWorkspaces ||
|
||||
isFiles ||
|
||||
isGroups ||
|
||||
isOrgs ||
|
||||
isOrgMembers ||
|
||||
isGroupMembers,
|
||||
[isWorkspaces, isFiles, isGroups, isOrgs, isOrgMembers, isGroupMembers],
|
||||
)
|
||||
const placeholder = useMemo(() => {
|
||||
if (isWorkspaces) {
|
||||
return 'Search Workspaces'
|
||||
} else if (isFiles) {
|
||||
return 'Search Files'
|
||||
} else if (isGroups) {
|
||||
return 'Search Groups'
|
||||
} else if (isOrgs) {
|
||||
return 'Search Organizations'
|
||||
} else if (isOrgMembers) {
|
||||
return 'Search Organization Members'
|
||||
} else if (isGroupMembers) {
|
||||
return 'Search Group Members'
|
||||
}
|
||||
}, [isWorkspaces, isFiles, isGroups, isOrgs, isOrgMembers, isGroupMembers])
|
||||
const [text, setText] = useState(query || '')
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
setText(query || '')
|
||||
} else {
|
||||
setText('')
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(value: string) => {
|
||||
if (isFiles) {
|
||||
if (value) {
|
||||
navigation(`/workspace/${id}/file/${fileId}?q=${encodeQuery(value)}`)
|
||||
} else {
|
||||
navigation(`/workspace/${id}/file/${fileId}`)
|
||||
}
|
||||
} else if (isWorkspaces) {
|
||||
if (value) {
|
||||
navigation(`/workspace?q=${encodeQuery(value)}`)
|
||||
} else {
|
||||
navigation(`/workspace`)
|
||||
}
|
||||
} else if (isGroups) {
|
||||
if (value) {
|
||||
navigation(`/group?q=${encodeQuery(value)}`)
|
||||
} else {
|
||||
navigation(`/group`)
|
||||
}
|
||||
} else if (isOrgs) {
|
||||
if (value) {
|
||||
navigation(`/organization?q=${encodeQuery(value)}`)
|
||||
} else {
|
||||
navigation(`/organization`)
|
||||
}
|
||||
} else if (isOrgMembers) {
|
||||
if (value) {
|
||||
navigation(`/organization/${id}/member?q=${encodeQuery(value)}`)
|
||||
} else {
|
||||
navigation(`/organization/${id}/member`)
|
||||
}
|
||||
} else if (isGroupMembers) {
|
||||
if (value) {
|
||||
navigation(`/group/${id}/member?q=${encodeQuery(value)}`)
|
||||
} else {
|
||||
navigation(`/group/${id}/member`)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
id,
|
||||
fileId,
|
||||
isFiles,
|
||||
isWorkspaces,
|
||||
isGroups,
|
||||
isOrgs,
|
||||
isOrgMembers,
|
||||
isGroupMembers,
|
||||
navigation,
|
||||
],
|
||||
)
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setText('')
|
||||
if (isFiles) {
|
||||
navigation(`/workspace/${id}/file/${fileId}`)
|
||||
} else if (isWorkspaces) {
|
||||
navigation(`/workspace`)
|
||||
} else if (isGroups) {
|
||||
navigation(`/group`)
|
||||
} else if (isOrgs) {
|
||||
navigation(`/organization`)
|
||||
} else if (isOrgMembers) {
|
||||
navigation(`/organization/${id}/member`)
|
||||
} else if (isGroupMembers) {
|
||||
navigation(`/group/${id}/member`)
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
fileId,
|
||||
isFiles,
|
||||
isWorkspaces,
|
||||
isGroups,
|
||||
isOrgs,
|
||||
isOrgMembers,
|
||||
isGroupMembers,
|
||||
navigation,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch(text)
|
||||
}
|
||||
},
|
||||
[text, handleSearch],
|
||||
)
|
||||
|
||||
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setText(event.target.value || '')
|
||||
}, [])
|
||||
|
||||
if (!isAvailable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('flex', 'flex-row', 'gap-0.5')}>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={IconSearch} className={cx('text-gray-300')} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={query || placeholder}
|
||||
variant="filled"
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
{query && (
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
icon={<IconClose />}
|
||||
onClick={handleClear}
|
||||
size="xs"
|
||||
aria-label="Clear"
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
{text || (isFocused && text) ? (
|
||||
<Button onClick={() => handleSearch(text)} isDisabled={!text}>
|
||||
Search
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBarSearch
|
@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Circle,
|
||||
Drawer as ChakraDrawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerFooter,
|
||||
IconButton,
|
||||
useDisclosure,
|
||||
Button,
|
||||
} from '@chakra-ui/react'
|
||||
import cx from 'classnames'
|
||||
import UploadList from '@/components/file/upload/upload-list'
|
||||
import { IconClearAll, IconUpload } from '@/lib'
|
||||
import { completedUploadsCleared } from '@/store/entities/uploads'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hook'
|
||||
import { uploadsDrawerClosed } from '@/store/ui/uploads-drawer'
|
||||
|
||||
const TopBarUploadDrawer = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const hasPendingUploads = useAppSelector(
|
||||
(state) =>
|
||||
state.entities.uploads.items.filter((e) => !e.completed).length > 0,
|
||||
)
|
||||
const openDrawer = useAppSelector((state) => state.ui.uploadsDrawer.open)
|
||||
const hasCompleted = useAppSelector(
|
||||
(state) =>
|
||||
state.entities.uploads.items.filter((e) => e.completed).length > 0,
|
||||
)
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (openDrawer) {
|
||||
onOpen()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}, [openDrawer, onOpen, onClose])
|
||||
|
||||
const handleClearCompleted = useCallback(() => {
|
||||
dispatch(completedUploadsCleared())
|
||||
}, [dispatch])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('flex', 'items-center', 'justify-center', 'relative')}>
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
icon={<IconUpload size="14px" />}
|
||||
aria-label=""
|
||||
onClick={onOpen}
|
||||
/>
|
||||
{hasPendingUploads && (
|
||||
<Circle size="15px" bg="red" position="absolute" top={0} right={0} />
|
||||
)}
|
||||
</div>
|
||||
<ChakraDrawer
|
||||
isOpen={isOpen}
|
||||
placement="right"
|
||||
onClose={() => {
|
||||
onClose()
|
||||
dispatch(uploadsDrawerClosed())
|
||||
}}
|
||||
finalFocusRef={buttonRef}
|
||||
>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>Uploads</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<UploadList />
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
{hasCompleted && (
|
||||
<Button
|
||||
className={cx('w-full')}
|
||||
leftIcon={<IconClearAll size="22px" />}
|
||||
onClick={handleClearCompleted}
|
||||
>
|
||||
Clear Completed Items
|
||||
</Button>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</ChakraDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBarUploadDrawer
|
Reference in New Issue
Block a user