This commit is contained in:
2024-04-21 14:42:52 +02:00
parent 4b69674ede
commit 8a25f53c99
10700 changed files with 55767 additions and 14201 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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