Initial commit

This commit is contained in:
John Wang 2023-05-15 08:51:32 +08:00
commit db896255d6
744 changed files with 56028 additions and 0 deletions

View file

@ -0,0 +1,9 @@
.logo-icon {
width: 40px;
height: 40px;
background: #ffffff url(../../assets/logo-icon.png) center center no-repeat;
background-size: contain;
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.05);
}

View file

@ -0,0 +1,114 @@
'use client'
import { useState } from 'react'
import s from './index.module.css'
import cn from 'classnames'
import useSWR from 'swr'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import I18n from '@/context/i18n'
import { useContext } from 'use-context-selector'
import { fetchMembers } from '@/service/common'
import { UserPlusIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import InviteModal from './invite-modal'
import InvitedModal from './invited-modal'
import Operation from './operation'
import { useAppContext } from '@/context/app-context'
import Avatar from '@/app/components/base/avatar'
import { useWorkspacesContext } from '@/context/workspace-context'
dayjs.extend(relativeTime)
const MembersPage = () => {
const { t } = useTranslation()
const RoleMap = {
owner: t('common.members.owner'),
admin: t('common.members.admin'),
normal: t('common.members.normal'),
}
const { locale } = useContext(I18n)
const { userProfile } = useAppContext()
const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers)
const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || []
const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email
const { workspaces } = useWorkspacesContext()
const currentWrokspace = workspaces.filter(item => item.current)?.[0]
return (
<>
<div>
<div className='flex items-center mb-4 p-3 bg-gray-50 rounded-2xl'>
<div className={cn(s['logo-icon'], 'shrink-0')}></div>
<div className='grow mx-2'>
<div className='text-sm font-medium text-gray-900'>{currentWrokspace.name}</div>
<div className='text-xs text-gray-500'>{t('common.userProfile.workspace')}</div>
</div>
<div className='
shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200
text-[13px] font-medium text-primary-600 bg-white
shadow-[0_1px_2px_rgba(16,24,40,0.05)] rounded-lg cursor-pointer
' onClick={() => setInviteModalVisible(true)}>
<UserPlusIcon className='w-4 h-4 mr-2 ' />
{t('common.members.invite')}
</div>
</div>
<div>
<div className='flex items-center py-[7px] border-b border-gray-200'>
<div className='grow px-3 text-xs font-medium text-gray-500'>{t('common.members.name')}</div>
<div className='shrink-0 w-[104px] text-xs font-medium text-gray-500'>{t('common.members.lastActive')}</div>
<div className='shrink-0 w-[96px] px-3 text-xs font-medium text-gray-500'>{t('common.members.role')}</div>
</div>
<div>
{
accounts.map(account => (
<div key={account.id} className='flex border-b border-gray-100'>
<div className='grow flex items-center py-2 px-3'>
<Avatar size={24} className='mr-2' name={account.name} />
<div className=''>
<div className='text-[13px] font-medium text-gray-700 leading-[18px]'>
{account.name}
{account.status === 'pending' && <span className='ml-1 text-xs text-[#DC6803]'>{t('common.members.pending')}</span>}
{userProfile.email === account.email && <span className='text-xs text-gray-500'>{t('common.members.you')}</span>}
</div>
<div className='text-xs text-gray-500 leading-[18px]'>{account.email}</div>
</div>
</div>
<div className='shrink-0 flex items-center w-[104px] py-2 text-[13px] text-gray-700'>{dayjs(Number((account.last_login_at || account.created_at)) * 1000).locale(locale === 'zh-Hans' ? 'zh-cn' : 'en').fromNow()}</div>
<div className='shrink-0 w-[96px] flex items-center'>
{
owner && account.role !== 'owner'
? <Operation member={account} onOperate={() => mutate()} />
: <div className='px-3 text-[13px] text-gray-700'>{RoleMap[account.role] || RoleMap.normal}</div>
}
</div>
</div>
))
}
</div>
</div>
</div>
{
inviteModalVisible && (
<InviteModal
onCancel={() => setInviteModalVisible(false)}
onSend={() => {
setInvitedModalVisible(true)
mutate()
}}
/>
)
}
{
invitedModalVisible && (
<InvitedModal
onCancel={() => setInvitedModalVisible(false)}
/>
)
}
</>
)
}
export default MembersPage

View file

@ -0,0 +1,4 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}

View file

@ -0,0 +1,74 @@
'use client'
import { useState } from 'react'
import { useContext } from 'use-context-selector'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import s from './index.module.css'
import { inviteMember } from '@/service/common'
import { emailRegex } from '@/config'
import { ToastContext } from '@/app/components/base/toast'
interface IInviteModalProps {
onCancel: () => void,
onSend: () => void,
}
const InviteModal = ({
onCancel,
onSend,
}: IInviteModalProps) => {
const { t } = useTranslation()
const [email, setEmail] = useState('')
const { notify } = useContext(ToastContext)
const handleSend = async () => {
if (emailRegex.test(email)) {
try {
const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin'} })
if (res.result === 'success') {
onCancel()
onSend()
}
} catch (e) {
}
} else {
notify({ type: 'error', message: t('common.members.emailInvalid') })
}
}
return (
<div className={s.wrap}>
<Modal isShow onClose={() => {}} className={s.modal}>
<div className='flex justify-between mb-2'>
<div className='text-xl font-semibold text-gray-900'>{t('common.members.inviteTeamMember')}</div>
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
</div>
<div className='mb-7 text-[13px] text-gray-500'>{t('common.members.inviteTeamMemberTip')}</div>
<div>
<div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div>
<input
className='
block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
appearance-none text-sm text-gray-900 rounded-lg
'
value={email}
onChange={e => setEmail(e.target.value)}
placeholder={t('common.members.emailPlaceholder') || ''}
/>
<Button
className='w-full text-sm font-medium'
onClick={handleSend}
type='primary'
>
{t('common.members.sendInvite')}
</Button>
</div>
</Modal>
</div>
)
}
export default InviteModal

View file

@ -0,0 +1,5 @@
.modal {
padding: 32px !important;
width: 480px !important;
background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !important;
}

View file

@ -0,0 +1,45 @@
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import s from './index.module.css'
interface IInvitedModalProps {
onCancel: () => void,
}
const InvitedModal = ({
onCancel
}: IInvitedModalProps) => {
const { t } = useTranslation()
return (
<div className={s.wrap}>
<Modal isShow onClose={() => {}} className={s.modal}>
<div className='flex justify-between mb-3'>
<div className='
w-12 h-12 flex items-center justify-center rounded-xl
bg-white border-[0.5px] border-gray-100
shadow-[0px_20px_24px_-4px_rgba(16,24,40,0.08),0px_8px_8px_-4px_rgba(16,24,40,0.03)]
'>
<CheckCircleIcon className='w-[22px] h-[22px] text-[#039855]' />
</div>
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
</div>
<div className='mb-1 text-xl font-semibold text-gray-900'>{t('common.members.invitationSent')}</div>
<div className='mb-10 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
<div className='flex justify-end'>
<Button
className='w-[96px] text-sm font-medium'
onClick={onCancel}
type='primary'
>
{t('common.members.ok')}
</Button>
</div>
</Modal>
</div>
)
}
export default InvitedModal

View file

@ -0,0 +1,3 @@
.popup {
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}

View file

@ -0,0 +1,136 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Fragment } from 'react'
import { useContext } from 'use-context-selector'
import { Menu, Transition } from '@headlessui/react'
import type { Member } from '@/models/common'
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
import { ToastContext } from '@/app/components/base/toast'
import s from './index.module.css'
import cn from 'classnames'
import { ChevronDownIcon, CheckIcon } from '@heroicons/react/24/outline'
const itemClassName = `
flex px-3 py-2 cursor-pointer hover:bg-gray-50 rounded-lg
`
const itemIconClassName = `
w-4 h-4 mt-[2px] mr-1 text-primary-600
`
const itemTitleClassName = `
leading-[20px] text-sm text-gray-700 whitespace-nowrap
`
const itemDescClassName = `
leading-[18px] text-xs text-gray-500 whitespace-nowrap
`
interface IOperationProps {
member: Member
onOperate: () => void
}
const Operation = ({
member,
onOperate
}: IOperationProps) => {
const { t } = useTranslation()
const RoleMap = {
owner: t('common.members.owner'),
admin: t('common.members.admin'),
normal: t('common.members.normal'),
}
const { notify } = useContext(ToastContext)
const handleDeleteMemberOrCancelInvitation = async () => {
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
onOperate()
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
} catch (e) {
}
}
const handleUpdateMemberRole = async (role: string) => {
try {
await updateMemberRole({
url: `/workspaces/current/members/${member.id}/update-role`,
body: { role }
})
onOperate()
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
} catch (e) {
}
}
return (
<Menu as="div" className="relative w-full h-full">
{
({ open }) => (
<>
<Menu.Button className={cn(
`
group flex items-center justify-between w-full h-full justify-center
hover:bg-gray-100 cursor-pointer ${open && 'bg-gray-100'}
text-[13px] text-gray-700 px-3
`
)}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={`w-4 h-4 group-hover:block ${open ? 'block' : 'hidden'}`} />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={cn(
`
absolute right-0 top-[52px] z-10 bg-white border-[0.5px] border-gray-200
divide-y divide-gray-100 origin-top-right rounded-lg
`,
s.popup
)}
>
<div className="px-1 py-1">
{
['admin', 'normal'].map(role => (
<Menu.Item>
<div className={itemClassName} onClick={() => handleUpdateMemberRole(role)}>
{
role === member.role
? <CheckIcon className={itemIconClassName} />
: <div className={itemIconClassName} />
}
<div>
<div className={itemTitleClassName}>{t(`common.members.${role}`)}</div>
<div className={itemDescClassName}>{t(`common.members.${role}Tip`)}</div>
</div>
</div>
</Menu.Item>
))
}
</div>
<Menu.Item>
<div className='px-1 py-1'>
<div className={itemClassName} onClick={handleDeleteMemberOrCancelInvitation}>
<div className={itemIconClassName} />
<div>
<div className={itemTitleClassName}>{t('common.members.removeFromTeam')}</div>
<div className={itemDescClassName}>{t('common.members.removeFromTeamTip')}</div>
</div>
</div>
</div>
</Menu.Item>
</Menu.Items>
</Transition>
</>
)
}
</Menu>
)
}
export default Operation