Initial commit
This commit is contained in:
38
web/app/components/base/app-icon/index.tsx
Normal file
38
web/app/components/base/app-icon/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FC } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import style from './style.module.css'
|
||||
|
||||
export type AppIconProps = {
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large'
|
||||
rounded?: boolean
|
||||
icon?: string
|
||||
background?: string
|
||||
className?: string
|
||||
innerIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
const AppIcon: FC<AppIconProps> = ({
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
background,
|
||||
className,
|
||||
innerIcon,
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
style.appIcon,
|
||||
size !== 'medium' && style[size],
|
||||
rounded && style.rounded,
|
||||
className ?? '',
|
||||
)}
|
||||
style={{
|
||||
background,
|
||||
}}
|
||||
>
|
||||
{innerIcon ? innerIcon : <>🤖</>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppIcon
|
||||
15
web/app/components/base/app-icon/style.module.css
Normal file
15
web/app/components/base/app-icon/style.module.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.appIcon {
|
||||
@apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0;
|
||||
}
|
||||
.appIcon.large {
|
||||
@apply w-10 h-10;
|
||||
}
|
||||
.appIcon.small {
|
||||
@apply w-8 h-8;
|
||||
}
|
||||
.appIcon.tiny {
|
||||
@apply w-6 h-6 text-base;
|
||||
}
|
||||
.appIcon.rounded {
|
||||
@apply rounded-full;
|
||||
}
|
||||
28
web/app/components/base/app-unavailable.tsx
Normal file
28
web/app/components/base/app-unavailable.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
import React, { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface IAppUnavailableProps {
|
||||
code?: number
|
||||
isUnknwonReason?: boolean
|
||||
unknownReason?: string
|
||||
}
|
||||
|
||||
const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
code = 404,
|
||||
isUnknwonReason,
|
||||
unknownReason,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center w-screen h-screen'>
|
||||
<h1 className='mr-5 h-[50px] leading-[50px] pr-5 text-[24px] font-medium'
|
||||
style={{
|
||||
borderRight: '1px solid rgba(0,0,0,.3)',
|
||||
}}>{code}</h1>
|
||||
<div className='text-sm'>{unknownReason || (isUnknwonReason ? t('share.common.appUnkonwError') : t('share.common.appUnavailable'))}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppUnavailable)
|
||||
75
web/app/components/base/auto-height-textarea/index.tsx
Normal file
75
web/app/components/base/auto-height-textarea/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import cn from 'classnames'
|
||||
|
||||
type IProps = {
|
||||
placeholder?: string
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
className?: string
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
autoFocus?: boolean
|
||||
controlFocus?: number
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
const AutoHeightTextarea = forwardRef(
|
||||
(
|
||||
{ value, onChange, placeholder, className, minHeight = 36, maxHeight = 96, autoFocus, controlFocus, onKeyDown, onKeyUp }: IProps,
|
||||
outerRef: any,
|
||||
) => {
|
||||
const ref = outerRef || useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const doFocus = () => {
|
||||
if (ref.current) {
|
||||
// console.log('focus')
|
||||
ref.current.setSelectionRange(value.length, value.length)
|
||||
ref.current.focus()
|
||||
return true
|
||||
}
|
||||
// console.log(autoFocus, 'not focus')
|
||||
return false
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (!doFocus()) {
|
||||
let hasFocus = false
|
||||
const runId = setInterval(() => {
|
||||
hasFocus = doFocus()
|
||||
if (hasFocus)
|
||||
clearInterval(runId)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus)
|
||||
focus()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (controlFocus)
|
||||
focus()
|
||||
}, [controlFocus])
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className={cn(className, 'invisible whitespace-pre-wrap break-all overflow-y-auto')} style={{ minHeight, maxHeight }}>
|
||||
{!value ? placeholder : value.replace(/\n$/, '\n ')}
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(className, 'absolute inset-0 resize-none overflow-hidden')}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default AutoHeightTextarea
|
||||
45
web/app/components/base/avatar/index.tsx
Normal file
45
web/app/components/base/avatar/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import cn from 'classnames'
|
||||
|
||||
interface IAvatarProps {
|
||||
name: string
|
||||
avatar?: string
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
const Avatar = ({
|
||||
name,
|
||||
avatar,
|
||||
size = 30,
|
||||
className
|
||||
}: IAvatarProps) => {
|
||||
const avatarClassName = `shrink-0 flex items-center rounded-full bg-primary-600`
|
||||
const style = { width: `${size}px`, height:`${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
|
||||
|
||||
if (avatar) {
|
||||
return (
|
||||
<img
|
||||
className={cn(avatarClassName, className)}
|
||||
style={style}
|
||||
alt={name}
|
||||
src={avatar}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(avatarClassName, className)}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={`text-center text-white scale-[0.4]`}
|
||||
style={style}
|
||||
>
|
||||
{name[0].toLocaleUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
174
web/app/components/base/block-input/index.tsx
Normal file
174
web/app/components/base/block-input/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '../toast'
|
||||
import { varHighlightHTML } from '../../app/configuration/base/var-highlight'
|
||||
|
||||
// regex to match the {{}} and replace it with a span
|
||||
const regex = /\{\{([^}]+)\}\}/g
|
||||
|
||||
export const getInputKeys = (value: string) => {
|
||||
const keys = value.match(regex)?.map((item) => {
|
||||
return item.replace('{{', '').replace('}}', '')
|
||||
}) || []
|
||||
const keyObj: Record<string, boolean> = {}
|
||||
// remove duplicate keys
|
||||
const res: string[] = []
|
||||
keys.forEach((key) => {
|
||||
if (keyObj[key])
|
||||
return
|
||||
|
||||
keyObj[key] = true
|
||||
res.push(key)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export type IBlockInputProps = {
|
||||
value: string
|
||||
className?: string // wrapper class
|
||||
highLightClassName?: string // class for the highlighted text default is text-blue-500
|
||||
onConfirm?: (value: string, keys: string[]) => void
|
||||
}
|
||||
|
||||
const BlockInput: FC<IBlockInputProps> = ({
|
||||
value = '',
|
||||
className,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// current is used to store the current value of the contentEditable element
|
||||
const [currentValue, setCurrentValue] = useState<string>(value)
|
||||
useEffect(() => {
|
||||
setCurrentValue(value)
|
||||
}, [value])
|
||||
|
||||
const isContentChanged = value !== currentValue
|
||||
|
||||
const contentEditableRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
if (isEditing && contentEditableRef.current) {
|
||||
// TODO: Focus at the click positon
|
||||
if (currentValue) {
|
||||
contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
|
||||
}
|
||||
contentEditableRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const style = classNames({
|
||||
'block px-4 py-1 w-full h-full text-sm text-gray-900 outline-0 border-0': true,
|
||||
'block-input--editing': isEditing,
|
||||
})
|
||||
|
||||
const coloredContent = (currentValue || '')
|
||||
.replace(regex, varHighlightHTML({ name: '$1' })) // `<span class="${highLightClassName}">{{$1}}</span>`
|
||||
.replace(/\n/g, '<br />')
|
||||
|
||||
// Not use useCallback. That will cause out callback get old data.
|
||||
const handleSubmit = () => {
|
||||
if (onConfirm) {
|
||||
const value = currentValue
|
||||
const keys = getInputKeys(value)
|
||||
const { isValid, errorKey, errorMessageKey } = checkKeys(keys)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey })
|
||||
})
|
||||
return
|
||||
}
|
||||
onConfirm(value, keys)
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
setCurrentValue(value)
|
||||
}, [value])
|
||||
|
||||
const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCurrentValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
|
||||
const TextAreaContentView = () => {
|
||||
return <div
|
||||
className={classNames(style, className)}
|
||||
dangerouslySetInnerHTML={{ __html: coloredContent }}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
}
|
||||
|
||||
const placeholder = ''
|
||||
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
|
||||
|
||||
const textAreaContent = (
|
||||
<div className='h-[180px] overflow-y-auto' onClick={() => setIsEditing(true)}>
|
||||
{isEditing
|
||||
? <div className='h-full px-4 py-1'>
|
||||
<textarea
|
||||
ref={contentEditableRef}
|
||||
className={classNames(editAreaClassName, 'block w-full h-full absolut3e resize-none')}
|
||||
placeholder={placeholder}
|
||||
onChange={onValueChange}
|
||||
value={currentValue}
|
||||
onBlur={() => {
|
||||
blur()
|
||||
if (!isContentChanged) {
|
||||
setIsEditing(false)
|
||||
}
|
||||
// click confirm also make blur. Then outter value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
: <TextAreaContentView />}
|
||||
</div>)
|
||||
|
||||
return (
|
||||
<div className={classNames('block-input w-full overflow-y-auto border-none rounded-lg')}>
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
<div className='flex item-center h-14 px-4'>
|
||||
{isContentChanged ? (
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{currentValue.length}</div>
|
||||
<div className='flex space-x-2'>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
className='w-20 !h-8 !text-[13px]'
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
type="primary"
|
||||
className='w-20 !h-8 !text-[13px]'
|
||||
>
|
||||
{t('common.operation.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<p className="leading-5 text-xs text-gray-500">
|
||||
{t('appDebug.promptTip')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BlockInput)
|
||||
47
web/app/components/base/button/index.tsx
Normal file
47
web/app/components/base/button/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { FC, MouseEventHandler } from 'react'
|
||||
import React from 'react'
|
||||
import Spinner from '../spinner'
|
||||
|
||||
export type IButtonProps = {
|
||||
type?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
children: React.ReactNode
|
||||
onClick?: MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
const Button: FC<IButtonProps> = ({
|
||||
type,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
loading = false,
|
||||
}) => {
|
||||
let style = 'cursor-pointer'
|
||||
switch (type) {
|
||||
case 'primary':
|
||||
style = (disabled || loading) ? 'bg-primary-200 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm'
|
||||
break
|
||||
case 'warning':
|
||||
style = (disabled || loading) ? 'bg-red-600/75 cursor-not-allowed text-white' : 'bg-red-600 hover:bg-red-600/75 hover:shadow-md cursor-pointer text-white hover:shadow-sm'
|
||||
break
|
||||
default:
|
||||
style = disabled ? 'border-solid border border-gray-200 bg-gray-200 cursor-not-allowed text-gray-800' : 'border-solid border border-gray-200 cursor-pointer text-gray-500 hover:bg-white hover:shadow-sm hover:border-gray-300'
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex justify-center items-center content-center h-9 leading-5 rounded-lg px-4 py-2 text-base ${style} ${className && className}`}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{children}
|
||||
{/* Spinner is hidden when loading is false */}
|
||||
<Spinner loading={loading} className='!text-white !h-3 !w-3 !border-2 !ml-1' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Button)
|
||||
51
web/app/components/base/confirm-ui/index.tsx
Normal file
51
web/app/components/base/confirm-ui/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type IConfirmUIProps = {
|
||||
type: 'info' | 'warning'
|
||||
title: string
|
||||
content: string
|
||||
confirmText?: string
|
||||
onConfirm: () => void
|
||||
cancelText?: string
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ConfirmUI: FC<IConfirmUIProps> = ({
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="w-[420px] rounded-lg p-7 bg-white">
|
||||
<div className='flex items-center'>
|
||||
{type === 'info' && (<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.3333 21.3333H16V16H14.6667M16 10.6667H16.0133M28 16C28 17.5759 27.6896 19.1363 27.0866 20.5922C26.4835 22.0481 25.5996 23.371 24.4853 24.4853C23.371 25.5996 22.0481 26.4835 20.5922 27.0866C19.1363 27.6896 17.5759 28 16 28C14.4241 28 12.8637 27.6896 11.4078 27.0866C9.95189 26.4835 8.62902 25.5996 7.51472 24.4853C6.40042 23.371 5.5165 22.0481 4.91345 20.5922C4.31039 19.1363 4 17.5759 4 16C4 12.8174 5.26428 9.76516 7.51472 7.51472C9.76516 5.26428 12.8174 4 16 4C19.1826 4 22.2348 5.26428 24.4853 7.51472C26.7357 9.76516 28 12.8174 28 16Z" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>)}
|
||||
{type === 'warning' && (<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 10.6667V16M16 21.3333H16.0133M28 16C28 17.5759 27.6896 19.1363 27.0866 20.5922C26.4835 22.0481 25.5996 23.371 24.4853 24.4853C23.371 25.5996 22.0481 26.4835 20.5922 27.0866C19.1363 27.6896 17.5759 28 16 28C14.4241 28 12.8637 27.6896 11.4078 27.0866C9.95189 26.4835 8.62902 25.5996 7.51472 24.4853C6.40042 23.371 5.5165 22.0481 4.91345 20.5922C4.31039 19.1363 4 17.5759 4 16C4 12.8174 5.26428 9.76516 7.51472 7.51472C9.76516 5.26428 12.8174 4 16 4C19.1826 4 22.2348 5.26428 24.4853 7.51472C26.7357 9.76516 28 12.8174 28 16Z" stroke="#FACA15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<div className='ml-4 text-lg text-gray-900'>{title}</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-1 ml-12'>
|
||||
<div className='text-sm leading-normal text-gray-500'>{content}</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3 mt-4 ml-12'>
|
||||
<div onClick={onConfirm} className='w-20 leading-9 text-center text-white border rounded-lg cursor-pointer h-9 border-color-primary-700 bg-primary-700'>{confirmText || t('common.operation.confirm')}</div>
|
||||
<div onClick={onCancel} className='w-20 leading-9 text-center text-gray-500 border rounded-lg cursor-pointer h-9 border-color-gray-200'>{cancelText || t('common.operation.cancel')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfirmUI)
|
||||
79
web/app/components/base/confirm/index.tsx
Normal file
79
web/app/components/base/confirm/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import ConfirmUI from '../confirm-ui'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// https://headlessui.com/react/dialog
|
||||
|
||||
type IConfirm = {
|
||||
className?: string
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
type?: 'info' | 'warning'
|
||||
title: string
|
||||
content: string
|
||||
confirmText?: string
|
||||
onConfirm: () => void
|
||||
cancelText?: string
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function Confirm({
|
||||
isShow,
|
||||
onClose,
|
||||
type = 'warning',
|
||||
title,
|
||||
content,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: IConfirm) {
|
||||
const { t } = useTranslation()
|
||||
const confirmTxt = confirmText || `${t('common.operation.confirm')}`
|
||||
const cancelTxt = cancelText || `${t('common.operation.cancel')}`
|
||||
return (
|
||||
<Transition appear show={isShow} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose} onClick={e => e.preventDefault()}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={'w-full max-w-md transform overflow-hidden rounded-2xl bg-white text-left align-middle shadow-xl transition-all'}>
|
||||
<ConfirmUI
|
||||
type={type}
|
||||
title={title}
|
||||
content={content}
|
||||
confirmText={confirmTxt}
|
||||
cancelText={cancelTxt}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
16
web/app/components/base/custom-icon/index.tsx
Normal file
16
web/app/components/base/custom-icon/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
type IconProps = {
|
||||
icon: any
|
||||
className?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const Icon: FC<IconProps> = ({ icon, className, ...other }) => {
|
||||
return (
|
||||
<img src={icon} className={`h-3 w-3 ${className}`} {...other} alt="icon" />
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
86
web/app/components/base/dialog/index.tsx
Normal file
86
web/app/components/base/dialog/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Fragment, useCallback } from 'react'
|
||||
import type { ElementType, ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
// https://headlessui.com/react/dialog
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
bodyClassName?: string
|
||||
footerClassName?: string
|
||||
titleAs?: ElementType
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
show: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const CustomDialog = ({
|
||||
className,
|
||||
titleClassName,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
titleAs,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => onClose?.(), [onClose])
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={close}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={classNames('w-full max-w-[800px] p-0 overflow-hidden text-left text-gray-900 align-middle transition-all transform bg-white shadow-xl rounded-2xl', className)}>
|
||||
{Boolean(title) && (
|
||||
<Dialog.Title
|
||||
as={titleAs || 'h3'}
|
||||
className={classNames('px-8 py-6 text-lg font-medium leading-6 text-gray-900', titleClassName)}
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div className={classNames('px-8 text-lg font-medium leading-6', bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{Boolean(footer) && (
|
||||
<div className={classNames('flex items-center justify-end gap-2 px-8 py-6', footerClassName)}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition >
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomDialog
|
||||
18
web/app/components/base/divider/index.tsx
Normal file
18
web/app/components/base/divider/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import React from 'react'
|
||||
import s from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
type?: 'horizontal' | 'vertical'
|
||||
// orientation?: 'left' | 'right' | 'center'
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
const Divider: FC<Props> = ({ type = 'horizontal', className = '', style }) => {
|
||||
return (
|
||||
<div className={`${s.divider} ${s[type]} ${className}`} style={style}></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Divider
|
||||
9
web/app/components/base/divider/style.module.css
Normal file
9
web/app/components/base/divider/style.module.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.divider {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
.horizontal {
|
||||
@apply w-full h-[0.5px] my-2;
|
||||
}
|
||||
.vertical {
|
||||
@apply w-[1px] h-full mx-2;
|
||||
}
|
||||
75
web/app/components/base/drawer/index.tsx
Normal file
75
web/app/components/base/drawer/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import { Dialog } from '@headlessui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '../button'
|
||||
|
||||
type DrawerProps = {
|
||||
title?: string
|
||||
description?: string
|
||||
panelClassname?: string
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
mask?: boolean
|
||||
isOpen: boolean
|
||||
// closable: boolean
|
||||
onClose: () => void
|
||||
onCancel?: () => void
|
||||
onOk?: () => void
|
||||
}
|
||||
|
||||
export default function Drawer({
|
||||
title = '',
|
||||
description = '',
|
||||
panelClassname = '',
|
||||
children,
|
||||
footer,
|
||||
mask = true,
|
||||
isOpen,
|
||||
onClose,
|
||||
onCancel,
|
||||
onOk,
|
||||
}: DrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog
|
||||
unmount={false}
|
||||
open={isOpen}
|
||||
onClose={() => onClose()}
|
||||
className="fixed z-30 inset-0 overflow-y-auto"
|
||||
>
|
||||
<div className="flex w-screen h-screen justify-end">
|
||||
{/* mask */}
|
||||
<Dialog.Overlay
|
||||
className={`z-40 fixed inset-0 ${!mask ? '' : 'bg-black bg-opacity-30'}`}
|
||||
/>
|
||||
<div className={`z-50 flex flex-col justify-between bg-white w-full
|
||||
max-w-sm p-6 overflow-hidden text-left align-middle
|
||||
shadow-xl ${panelClassname}`}>
|
||||
<>
|
||||
{title && <Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>}
|
||||
{description && <Dialog.Description className='text-gray-500 text-xs font-normal mt-2'>{description}</Dialog.Description>}
|
||||
{children}
|
||||
</>
|
||||
{footer || (footer === null
|
||||
? null
|
||||
: <div className="mt-10 flex flex-row justify-end">
|
||||
<Button
|
||||
className='mr-2'
|
||||
onClick={() => {
|
||||
onCancel && onCancel()
|
||||
}}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOk && onOk()
|
||||
}}>{t('common.operation.save')}</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
37
web/app/components/base/ga/index.tsx
Normal file
37
web/app/components/base/ga/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { FC } from 'react'
|
||||
import Script from 'next/script'
|
||||
|
||||
export enum GaType {
|
||||
admin = 'admin',
|
||||
webapp = 'webapp',
|
||||
}
|
||||
|
||||
const gaIdMaps = {
|
||||
[GaType.admin]: 'G-DM9497FN4V',
|
||||
[GaType.webapp]: 'G-2MFWXK7WYT',
|
||||
}
|
||||
|
||||
export interface IGAProps {
|
||||
gaType: GaType
|
||||
}
|
||||
|
||||
|
||||
const GA: FC<IGAProps> = ({
|
||||
gaType
|
||||
}) => {
|
||||
return (
|
||||
<Script
|
||||
id="gtag-base"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer', '${gaIdMaps[gaType]}');
|
||||
`,
|
||||
}} />
|
||||
)
|
||||
}
|
||||
export default React.memo(GA)
|
||||
45
web/app/components/base/input/index.tsx
Normal file
45
web/app/components/base/input/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
|
||||
type InputProps = {
|
||||
placeholder?: string
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: (v: any) => void
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
type?: string
|
||||
showPrefix?: React.ReactNode
|
||||
prefixIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
const GlassIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M12.25 12.25L10.2084 10.2083M11.6667 6.70833C11.6667 9.44675 9.44675 11.6667 6.70833 11.6667C3.96992 11.6667 1.75 9.44675 1.75 6.70833C1.75 3.96992 3.96992 1.75 6.70833 1.75C9.44675 1.75 11.6667 3.96992 11.6667 6.70833Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const Input: FC<InputProps> = ({ value, defaultValue, onChange, className = '', wrapperClassName = '', placeholder, type, showPrefix, prefixIcon }) => {
|
||||
const [localValue, setLocalValue] = useState(value ?? defaultValue)
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`relative inline-flex w-full ${wrapperClassName}`}>
|
||||
{showPrefix && <span className={s.prefix}>{prefixIcon ?? <GlassIcon className='h-3.5 w-3.5 stroke-current text-gray-700 stroke-2' />}</span>}
|
||||
<input
|
||||
type={type ?? 'text'}
|
||||
className={`${s.input} ${showPrefix ? '!pl-7' : ''} ${className}`}
|
||||
placeholder={placeholder ?? (showPrefix ? t('common.operation.search') : 'please input')}
|
||||
value={localValue}
|
||||
onChange={(e) => {
|
||||
setLocalValue(e.target.value)
|
||||
onChange && onChange(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input
|
||||
7
web/app/components/base/input/style.module.css
Normal file
7
web/app/components/base/input/style.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.input {
|
||||
@apply inline-flex h-7 w-full py-1 px-2 rounded-lg text-xs leading-normal;
|
||||
@apply bg-gray-100 caret-primary-600 hover:bg-gray-100 focus:ring-1 focus:ring-inset focus:ring-gray-200 focus-visible:outline-none focus:bg-white placeholder:text-gray-400;
|
||||
}
|
||||
.prefix {
|
||||
@apply whitespace-nowrap absolute left-2 self-center
|
||||
}
|
||||
29
web/app/components/base/loading/index.tsx
Normal file
29
web/app/components/base/loading/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
import './style.css'
|
||||
interface ILoadingProps {
|
||||
type?: 'area' | 'app'
|
||||
}
|
||||
const Loading = (
|
||||
{ type = 'area' }: ILoadingProps = { type: 'area' }
|
||||
) => {
|
||||
return (
|
||||
<div className={`flex w-full justify-center items-center ${type === 'app' ? 'h-full' : ''}`}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className='spin-animation'>
|
||||
<g clipPath="url(#clip0_324_2488)">
|
||||
<path d="M15 0H10C9.44772 0 9 0.447715 9 1V6C9 6.55228 9.44772 7 10 7H15C15.5523 7 16 6.55228 16 6V1C16 0.447715 15.5523 0 15 0Z" fill="#1C64F2" />
|
||||
<path opacity="0.5" d="M15 9H10C9.44772 9 9 9.44772 9 10V15C9 15.5523 9.44772 16 10 16H15C15.5523 16 16 15.5523 16 15V10C16 9.44772 15.5523 9 15 9Z" fill="#1C64F2" />
|
||||
<path opacity="0.1" d="M6 9H1C0.447715 9 0 9.44772 0 10V15C0 15.5523 0.447715 16 1 16H6C6.55228 16 7 15.5523 7 15V10C7 9.44772 6.55228 9 6 9Z" fill="#1C64F2" />
|
||||
<path opacity="0.2" d="M6 0H1C0.447715 0 0 0.447715 0 1V6C0 6.55228 0.447715 7 1 7H6C6.55228 7 7 6.55228 7 6V1C7 0.447715 6.55228 0 6 0Z" fill="#1C64F2" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_324_2488">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Loading
|
||||
41
web/app/components/base/loading/style.css
Normal file
41
web/app/components/base/loading/style.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.spin-animation path {
|
||||
animation: custom 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes custom {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
25% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(2) {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(3) {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(4) {
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
87
web/app/components/base/markdown.tsx
Normal file
87
web/app/components/base/markdown.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import RemarkMath from "remark-math";
|
||||
import RemarkBreaks from "remark-breaks";
|
||||
import RehypeKatex from "rehype-katex";
|
||||
import RemarkGfm from "remark-gfm";
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
import { useRef, useState, RefObject, useEffect } from "react";
|
||||
// import { copyToClipboard } from "../utils";
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
|
||||
return (
|
||||
<pre ref={ref}>
|
||||
<span
|
||||
className="copy-code-button"
|
||||
onClick={() => {
|
||||
if (ref.current) {
|
||||
const code = ref.current.innerText;
|
||||
// copyToClipboard(code);
|
||||
}
|
||||
}}
|
||||
></span>
|
||||
{props.children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const useLazyLoad = (ref: RefObject<Element>): boolean => {
|
||||
const [isIntersecting, setIntersecting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIntersecting(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return isIntersecting;
|
||||
};
|
||||
|
||||
export function Markdown(props: { content: string }) {
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
children={String(children).replace(/\n$/, '')}
|
||||
style={atelierHeathLight}
|
||||
language={match[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
/>
|
||||
) : (
|
||||
<code {...props} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
}}
|
||||
linkTarget={"_blank"}
|
||||
>
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/app/components/base/modal/index.tsx
Normal file
73
web/app/components/base/modal/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
// https://headlessui.com/react/dialog
|
||||
|
||||
type IModal = {
|
||||
className?: string
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
className,
|
||||
isShow,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
closable = false,
|
||||
}: IModal) {
|
||||
return (
|
||||
<Transition appear show={isShow} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={`w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all ${className}`}>
|
||||
{title && <Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>}
|
||||
{description && <Dialog.Description className='text-gray-500 text-xs font-normal mt-2'>
|
||||
{description}
|
||||
</Dialog.Description>}
|
||||
{closable
|
||||
&& <div className='absolute top-6 right-6 w-5 h-5 rounded-2xl flex items-center justify-center hover:cursor-pointer hover:bg-gray-100'>
|
||||
<XMarkIcon className='w-4 h-4 text-gray-500' onClick={onClose} />
|
||||
</div>}
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
52
web/app/components/base/pagination/index.tsx
Normal file
52
web/app/components/base/pagination/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { Pagination } from 'react-headless-pagination'
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
current: number
|
||||
onChange: (cur: number) => void
|
||||
total: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
const CustomizedPagination: FC<Props> = ({ current, onChange, total, limit = 10 }) => {
|
||||
const { t } = useTranslation()
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
return (
|
||||
<Pagination
|
||||
className="flex items-center w-full h-10 text-sm select-none mt-8"
|
||||
currentPage={current}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
setCurrentPage={onChange}
|
||||
totalPages={totalPages}
|
||||
truncableClassName="w-8 px-0.5 text-center"
|
||||
truncableText="..."
|
||||
>
|
||||
<Pagination.PrevButton
|
||||
disabled={current === 0}
|
||||
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${current === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600'}`} >
|
||||
<ArrowLeftIcon className="mr-3 h-3 w-3" />
|
||||
{t('appLog.table.pagination.previous')}
|
||||
</Pagination.PrevButton>
|
||||
<div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
|
||||
<Pagination.PageButton
|
||||
activeClassName="bg-primary-50 text-primary-600"
|
||||
className="flex items-center justify-center h-8 w-8 rounded-lg cursor-pointer"
|
||||
inactiveClassName="text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<Pagination.NextButton
|
||||
disabled={current === totalPages - 1}
|
||||
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${current === totalPages - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600'}`} >
|
||||
{t('appLog.table.pagination.next')}
|
||||
<ArrowRightIcon className="ml-3 h-3 w-3" />
|
||||
</Pagination.NextButton>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomizedPagination
|
||||
3
web/app/components/base/pagination/style.module.css
Normal file
3
web/app/components/base/pagination/style.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.pagination li {
|
||||
list-style: none;
|
||||
}
|
||||
80
web/app/components/base/panel/index.tsx
Normal file
80
web/app/components/base/panel/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import React, { FC, useEffect } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { ChevronRightIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
|
||||
export interface IPanelProps {
|
||||
className?: string
|
||||
headerIcon: React.ReactNode
|
||||
title: React.ReactNode
|
||||
headerRight?: React.ReactNode
|
||||
bodyClassName?: string
|
||||
children: React.ReactNode
|
||||
keepUnFold?: boolean
|
||||
foldDisabled?: boolean
|
||||
onFoldChange?: (fold: boolean) => void
|
||||
controlUnFold?: number
|
||||
controlFold?: number
|
||||
}
|
||||
|
||||
const Panel: FC<IPanelProps> = ({
|
||||
className,
|
||||
headerIcon,
|
||||
title,
|
||||
headerRight,
|
||||
bodyClassName,
|
||||
children,
|
||||
keepUnFold,
|
||||
foldDisabled = false,
|
||||
onFoldChange,
|
||||
controlUnFold,
|
||||
controlFold
|
||||
}) => {
|
||||
const [fold, { setTrue: setFold, setFalse: setUnFold, toggle: toggleFold }] = useBoolean(keepUnFold ? false : true)
|
||||
useEffect(() => {
|
||||
onFoldChange?.(fold)
|
||||
}, [fold])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlUnFold) {
|
||||
setUnFold()
|
||||
}
|
||||
}, [controlUnFold])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlFold) {
|
||||
setFold()
|
||||
}
|
||||
}, [controlFold])
|
||||
|
||||
// overflow-hidden
|
||||
return (
|
||||
<div className={cn(className, 'w-full rounded-xl border border-gray-100 overflow-hidden select-none')}>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => (!foldDisabled && !keepUnFold) && toggleFold()}
|
||||
className={cn(!fold && 'border-b border-gray-100', 'flex justify-between items-center h-12 bg-gray-50 pl-4 pr-2')}>
|
||||
<div className='flex items-center gap-2'>
|
||||
{headerIcon}
|
||||
<div className='text-gray-900 text-sm'>{title}</div>
|
||||
</div>
|
||||
{(fold && headerRight) ? headerRight : ''}
|
||||
{!headerRight && !keepUnFold && (
|
||||
<ChevronRightIcon className={cn(!fold && 'rotate-90', 'mr-2 cursor-pointer')} width="16" height="16">
|
||||
</ChevronRightIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
|
||||
{!fold && !foldDisabled && (
|
||||
<div className={cn(bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Panel)
|
||||
98
web/app/components/base/popover/index.tsx
Normal file
98
web/app/components/base/popover/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import { Fragment, cloneElement, useRef } from 'react'
|
||||
import s from './style.module.css'
|
||||
|
||||
type IPopover = {
|
||||
className?: string
|
||||
htmlContent: React.ReactNode
|
||||
trigger?: 'click' | 'hover'
|
||||
position?: 'bottom' | 'br'
|
||||
btnElement?: string | React.ReactNode
|
||||
btnClassName?: string | ((open: boolean) => string)
|
||||
}
|
||||
|
||||
const timeoutDuration = 100
|
||||
|
||||
export default function CustomPopover({
|
||||
trigger = 'hover',
|
||||
position = 'bottom',
|
||||
htmlContent,
|
||||
btnElement,
|
||||
className,
|
||||
btnClassName,
|
||||
}: IPopover) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const timeOutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const onMouseEnter = (isOpen: boolean) => {
|
||||
timeOutRef.current && clearTimeout(timeOutRef.current)
|
||||
!isOpen && buttonRef.current?.click()
|
||||
}
|
||||
|
||||
const onMouseLeave = (isOpen: boolean) => {
|
||||
timeOutRef.current = setTimeout(() => {
|
||||
isOpen && buttonRef.current?.click()
|
||||
}, timeoutDuration)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }: { open: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{...(trigger !== 'hover'
|
||||
? {}
|
||||
: {
|
||||
onMouseLeave: () => onMouseLeave(open),
|
||||
onMouseEnter: () => onMouseEnter(open),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Popover.Button
|
||||
ref={buttonRef}
|
||||
className={`group ${s.popupBtn} ${open ? '' : 'bg-gray-100'} ${!btnClassName ? '' : typeof btnClassName === 'string' ? btnClassName : btnClassName?.(open)}`}
|
||||
>
|
||||
{btnElement}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel
|
||||
className={`${s.popupPanel} ${position === 'br' ? 'right-0' : 'transform -translate-x-1/2 left-1/2'} ${className}`}
|
||||
{...(trigger !== 'hover'
|
||||
? {}
|
||||
: {
|
||||
onMouseLeave: () => onMouseLeave(open),
|
||||
onMouseEnter: () => onMouseEnter(open),
|
||||
})
|
||||
}>
|
||||
<div
|
||||
className={s.panelContainer}
|
||||
{...(trigger !== 'hover'
|
||||
? {}
|
||||
: {
|
||||
onMouseLeave: () => onMouseLeave(open),
|
||||
onMouseEnter: () => onMouseEnter(open),
|
||||
})
|
||||
}
|
||||
>
|
||||
{cloneElement(htmlContent as React.ReactElement, {
|
||||
onClose: () => onMouseLeave(open),
|
||||
})}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
9
web/app/components/base/popover/style.module.css
Normal file
9
web/app/components/base/popover/style.module.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.popupBtn {
|
||||
@apply inline-flex items-center bg-white px-3 py-2 rounded-lg text-base border border-gray-200 font-medium hover:bg-gray-100 focus:outline-none
|
||||
}
|
||||
.popupPanel {
|
||||
@apply absolute z-10 w-full max-w-sm px-4 mt-1 sm:px-0 lg:max-w-3xl
|
||||
}
|
||||
.panelContainer {
|
||||
@apply overflow-hidden bg-white w-full rounded-lg shadow-lg ring-1 ring-black ring-opacity-5
|
||||
}
|
||||
84
web/app/components/base/portal-to-follow-elem/index.tsx
Normal file
84
web/app/components/base/portal-to-follow-elem/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import React, { FC, useEffect, useState, useRef } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
export interface IPortalToFollowElementProps {
|
||||
portalElem: React.ReactNode
|
||||
children: React.ReactNode
|
||||
controlShow?: number
|
||||
controlHide?: number
|
||||
}
|
||||
|
||||
const PortalToFollowElement: FC<IPortalToFollowElementProps> = ({
|
||||
portalElem,
|
||||
children,
|
||||
controlShow,
|
||||
controlHide
|
||||
}) => {
|
||||
const [isShowContent, { setTrue: showContent, setFalse: hideContent, toggle: toggleContent }] = useBoolean(false)
|
||||
const [wrapElem, setWrapElem] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (controlShow) {
|
||||
showContent()
|
||||
}
|
||||
}, [controlShow])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlHide) {
|
||||
hideContent()
|
||||
}
|
||||
}, [controlHide])
|
||||
|
||||
// todo use click outside hidden
|
||||
const triggerElemRef = useRef<HTMLElement>(null)
|
||||
|
||||
const calLoc = () => {
|
||||
const triggerElem = triggerElemRef.current
|
||||
if (!triggerElem) {
|
||||
return {
|
||||
display: 'none'
|
||||
}
|
||||
}
|
||||
const {
|
||||
left: triggerLeft,
|
||||
top: triggerTop,
|
||||
height
|
||||
} = triggerElem.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: triggerLeft,
|
||||
top: triggerTop + height,
|
||||
zIndex: 999
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isShowContent) {
|
||||
const holder = document.createElement('div')
|
||||
const root = createRoot(holder)
|
||||
const style = calLoc()
|
||||
root.render(
|
||||
<div style={style as React.CSSProperties}>
|
||||
{portalElem}
|
||||
</div>
|
||||
)
|
||||
document.body.appendChild(holder)
|
||||
setWrapElem(holder)
|
||||
console.log(holder)
|
||||
} else {
|
||||
wrapElem?.remove?.()
|
||||
setWrapElem(null)
|
||||
}
|
||||
}, [isShowContent])
|
||||
|
||||
return (
|
||||
<div ref={triggerElemRef as any} onClick={toggleContent}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PortalToFollowElement)
|
||||
24
web/app/components/base/radio/component/group/index.tsx
Normal file
24
web/app/components/base/radio/component/group/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import cn from 'classnames'
|
||||
import RadioGroupContext from '../../context'
|
||||
import s from '../../style.module.css'
|
||||
|
||||
export type TRadioGroupProps = {
|
||||
children?: ReactElement | ReactElement[]
|
||||
value?: string | number
|
||||
className?: string
|
||||
onChange?: (value: any) => void
|
||||
}
|
||||
|
||||
export default function Group({ children, value, onChange, className = '' }: TRadioGroupProps): JSX.Element {
|
||||
const onRadioChange = (value: any) => {
|
||||
onChange?.(value)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex items-center bg-gray-50', s.container, className)}>
|
||||
<RadioGroupContext.Provider value={{ value, onChange: onRadioChange }}>
|
||||
{children}
|
||||
</RadioGroupContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
web/app/components/base/radio/component/radio/index.tsx
Normal file
61
web/app/components/base/radio/component/radio/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useId } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import RadioGroupContext from '../../context'
|
||||
import s from '../../style.module.css'
|
||||
|
||||
export type IRadioProps = {
|
||||
className?: string
|
||||
children?: string | ReactElement
|
||||
checked?: boolean
|
||||
value?: string | number
|
||||
disabled?: boolean
|
||||
onChange?: (e: any) => void
|
||||
}
|
||||
|
||||
export default function Radio({
|
||||
className = '',
|
||||
children = '',
|
||||
checked,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: IRadioProps): JSX.Element {
|
||||
const groupContext = useContext(RadioGroupContext)
|
||||
const labelId = useId()
|
||||
const handleChange = (e: any) => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
onChange?.(e)
|
||||
groupContext?.onChange(e)
|
||||
}
|
||||
|
||||
const isChecked = groupContext ? groupContext.value === value : checked
|
||||
const divClassName = `
|
||||
flex items-center py-1 relative
|
||||
px-7 cursor-pointer hover:bg-gray-200 rounded
|
||||
`
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
s.label,
|
||||
disabled ? s.disabled : '',
|
||||
isChecked ? 'bg-white shadow' : '',
|
||||
divClassName,
|
||||
className)}
|
||||
onClick={() => handleChange(value)}
|
||||
>
|
||||
{children && (
|
||||
<label className={
|
||||
cn('text-sm cursor-pointer')
|
||||
}
|
||||
id={labelId}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
web/app/components/base/radio/context/index.tsx
Normal file
6
web/app/components/base/radio/context/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
const RadioGroupContext = createContext<any>(null)
|
||||
export default RadioGroupContext
|
||||
15
web/app/components/base/radio/index.tsx
Normal file
15
web/app/components/base/radio/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type React from 'react'
|
||||
import type { IRadioProps } from './component/radio'
|
||||
import RadioComps from './component/radio'
|
||||
import Group from './component/group'
|
||||
|
||||
type CompoundedComponent = {
|
||||
Group: typeof Group
|
||||
} & React.ForwardRefExoticComponent<IRadioProps & React.RefAttributes<HTMLElement>>
|
||||
|
||||
const Radio = RadioComps as CompoundedComponent
|
||||
/**
|
||||
* Radio 组件出现一般是以一组的形式出现
|
||||
*/
|
||||
Radio.Group = Group
|
||||
export default Radio
|
||||
13
web/app/components/base/radio/style.module.css
Normal file
13
web/app/components/base/radio/style.module.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.container {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.label:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
73
web/app/components/base/select-support-portal/index.tsx
Normal file
73
web/app/components/base/select-support-portal/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import React, { FC, useState } from 'react'
|
||||
import PortalToFollowElem from '../portal-to-follow-elem'
|
||||
import { ChevronDownIcon, CheckIcon } from '@heroicons/react/24/outline'
|
||||
import cn from 'classnames'
|
||||
|
||||
export interface ISelectProps<T> {
|
||||
value: T
|
||||
items: { value: T, name: string }[]
|
||||
onChange: (value: T) => void
|
||||
}
|
||||
|
||||
const Select: FC<ISelectProps<string | number>> = ({
|
||||
value,
|
||||
items,
|
||||
onChange
|
||||
}) => {
|
||||
const [controlHide, setControlHide] = useState(0)
|
||||
const itemsElement = items.map(item => {
|
||||
const isSelected = item.value === value
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn('relative h-9 leading-9 px-10 rounded-lg text-sm text-gray-700 hover:bg-gray-100')}
|
||||
onClick={() => {
|
||||
onChange(item.value)
|
||||
setControlHide(Date.now())
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className='absolute left-4 top-1/2 translate-y-[-50%] flex items-center justify-center w-4 h-4 text-primary-600'>
|
||||
<CheckIcon width={16} height={16}></CheckIcon>
|
||||
</div>
|
||||
)}
|
||||
{item.name}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<PortalToFollowElem
|
||||
portalElem={(
|
||||
<div
|
||||
className='p-1 rounded-lg bg-white cursor-pointer'
|
||||
style={{
|
||||
boxShadow: '0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
>
|
||||
{itemsElement}
|
||||
</div>
|
||||
)}
|
||||
controlHide={controlHide}
|
||||
>
|
||||
<div className='relative '>
|
||||
<div className='flex items-center h-9 px-3 gap-1 cursor-pointer hover:bg-gray-50'>
|
||||
<div className='text-sm text-gray-700'>{items.find(i => i.value === value)?.name}</div>
|
||||
<ChevronDownIcon width={16} height={16} />
|
||||
</div>
|
||||
{/* <div
|
||||
className='absolute z-50 left-0 top-9 p-1 w-[112px] rounded-lg bg-white'
|
||||
style={{
|
||||
boxShadow: '0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
>
|
||||
{itemsElement}
|
||||
</div> */}
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(Select)
|
||||
224
web/app/components/base/select/index.tsx
Normal file
224
web/app/components/base/select/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
import { Combobox, Listbox, Transition } from '@headlessui/react'
|
||||
import classNames from 'classnames'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const defaultItems = [
|
||||
{ value: 1, name: 'option1' },
|
||||
{ value: 2, name: 'option2' },
|
||||
{ value: 3, name: 'option3' },
|
||||
{ value: 4, name: 'option4' },
|
||||
{ value: 5, name: 'option5' },
|
||||
{ value: 6, name: 'option6' },
|
||||
{ value: 7, name: 'option7' },
|
||||
]
|
||||
|
||||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ISelectProps = {
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
items?: Item[]
|
||||
defaultValue?: number | string
|
||||
disabled?: boolean
|
||||
onSelect: (value: Item) => void
|
||||
allowSearch?: boolean
|
||||
bgClassName?: string
|
||||
placeholder?: string
|
||||
}
|
||||
const Select: FC<ISelectProps> = ({
|
||||
className,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
allowSearch = true,
|
||||
bgClassName = 'bg-gray-100',
|
||||
}) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
const existed = items.find((item: Item) => item.value === defaultValue)
|
||||
if (existed) {
|
||||
defaultSelect = existed
|
||||
}
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue])
|
||||
|
||||
const filteredItems: Item[]
|
||||
= query === ''
|
||||
? items
|
||||
: items.filter((item) => {
|
||||
return item.name.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
disabled={disabled}
|
||||
value={selectedItem}
|
||||
className={className}
|
||||
onChange={(value: Item) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
setOpen(false)
|
||||
onSelect(value)
|
||||
}
|
||||
}}>
|
||||
<div className={classNames('relative')}>
|
||||
<div className='group text-gray-800'>
|
||||
{allowSearch
|
||||
? <Combobox.Input
|
||||
className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-not-allowed`}
|
||||
onChange={(event) => {
|
||||
if (!disabled)
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
displayValue={(item: Item) => item?.name}
|
||||
/>
|
||||
: <Combobox.Button onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
} className={`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200`}>
|
||||
{selectedItem?.name}
|
||||
</Combobox.Button>}
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none group-hover:bg-gray-200" onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
}>
|
||||
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<Combobox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
|
||||
{filteredItems.map((item: Item) => (
|
||||
<Combobox.Option
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={({ active }: { active: boolean }) =>
|
||||
classNames(
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700',
|
||||
active ? 'bg-gray-100' : '',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
<span className={classNames('block truncate', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</div>
|
||||
</Combobox >
|
||||
)
|
||||
}
|
||||
|
||||
const SimpleSelect: FC<ISelectProps> = ({
|
||||
className,
|
||||
wrapperClassName,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const localPlaceholder = placeholder || t('common.placeholder.select')
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
const existed = items.find((item: Item) => item.value === defaultValue)
|
||||
if (existed) {
|
||||
defaultSelect = existed
|
||||
}
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue])
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
value={selectedItem}
|
||||
onChange={(value: Item) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
onSelect(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`relative h-9 ${wrapperClassName}`}>
|
||||
<Listbox.Button className={`w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer ${className}`}>
|
||||
<span className={classNames("block truncate text-left", !selectedItem?.name && 'text-gray-400')}>{selectedItem?.name ?? localPlaceholder}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
|
||||
{items.map((item: Item) => (
|
||||
<Listbox.Option
|
||||
key={item.value}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''
|
||||
}`
|
||||
}
|
||||
value={item}
|
||||
disabled={disabled}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
<span className={classNames('block truncate', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
export { SimpleSelect }
|
||||
export default React.memo(Select)
|
||||
125
web/app/components/base/select/locale.tsx
Normal file
125
web/app/components/base/select/locale.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
export const LOCALES = [
|
||||
{ value: 'en', name: 'EN' },
|
||||
{ value: 'zh-Hans', name: '简体中文' },
|
||||
]
|
||||
export const RFC_LOCALES = [
|
||||
{ value: 'en-US', name: 'EN' },
|
||||
{ value: 'zh-Hans', name: '简体中文' },
|
||||
]
|
||||
interface ISelectProps {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
items,
|
||||
value,
|
||||
onChange
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
return (
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex w-full justify-center items-center
|
||||
rounded-lg px-2 py-1
|
||||
text-gray-600 text-xs font-medium
|
||||
border border-gray-200">
|
||||
<GlobeAltIcon className="w-5 h-5 mr-2 " aria-hidden="true" />
|
||||
{item?.name}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<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="absolute right-0 mt-2 w-28 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
{items.map((item) => {
|
||||
return <Menu.Item key={item.value}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${active ? 'bg-gray-100' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange && onChange(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InputSelect({
|
||||
items,
|
||||
value,
|
||||
onChange
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Menu as="div" className="w-full">
|
||||
<div>
|
||||
<Menu.Button className="iappearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 sm:text-sm h-[38px] text-left">
|
||||
{item?.name}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<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="absolute right-0 mt-2 w-full origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||
<div className="px-1 py-1 ">
|
||||
{items.map((item) => {
|
||||
return <Menu.Item key={item.value}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${active ? 'bg-gray-100' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
onClick={() => {
|
||||
onChange && onChange(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
web/app/components/base/slider/index.tsx
Normal file
24
web/app/components/base/slider/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import ReactSlider from 'react-slider'
|
||||
import './style.css'
|
||||
type ISliderProps = {
|
||||
value: number
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const Slider: React.FC<ISliderProps> = ({ max, min, step, value, onChange }) => {
|
||||
return <ReactSlider
|
||||
value={value}
|
||||
min={min || 0}
|
||||
max={max || 100}
|
||||
step={step || 1}
|
||||
className="slider"
|
||||
thumbClassName="slider-thumb"
|
||||
trackClassName="slider-track"
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
|
||||
export default Slider
|
||||
28
web/app/components/base/slider/style.css
Normal file
28
web/app/components/base/slider/style.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.slider {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
|
||||
.slider-thumb:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
background-color: #9CA3AF;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.slider-track-1 {
|
||||
background-color: #E5E7EB;
|
||||
}
|
||||
24
web/app/components/base/spinner/index.tsx
Normal file
24
web/app/components/base/spinner/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
loading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode | string
|
||||
}
|
||||
|
||||
const Spinner: FC<Props> = ({ loading = false, children, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={`inline-block text-gray-200 h-4 w-4 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] ${loading ? 'motion-reduce:animate-[spin_1.5s_linear_infinite]' : 'hidden'} ${className ?? ''}`}
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"
|
||||
>Loading...</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner
|
||||
55
web/app/components/base/switch/index.tsx
Normal file
55
web/app/components/base/switch/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { Switch as OriginalSwitch } from '@headlessui/react'
|
||||
|
||||
type SwitchProps = {
|
||||
onChange: (value: boolean) => void
|
||||
size?: 'md' | 'lg'
|
||||
defaultValue?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const Switch = ({ onChange, size = 'lg', defaultValue = false, disabled = false }: SwitchProps) => {
|
||||
const [enabled, setEnabled] = useState(defaultValue)
|
||||
const wrapStyle = {
|
||||
lg: 'h-6 w-11',
|
||||
md: 'h-4 w-7'
|
||||
}
|
||||
|
||||
const circleStyle = {
|
||||
lg: 'h-5 w-5',
|
||||
md: 'h-3 w-3'
|
||||
}
|
||||
|
||||
const translateLeft = {
|
||||
lg: 'translate-x-5',
|
||||
md: 'translate-x-3'
|
||||
}
|
||||
return (
|
||||
<OriginalSwitch
|
||||
checked={enabled}
|
||||
onChange={(checked: boolean) => {
|
||||
if (disabled) return;
|
||||
setEnabled(checked)
|
||||
onChange(checked)
|
||||
}}
|
||||
className={classNames(
|
||||
wrapStyle[size],
|
||||
enabled ? 'bg-blue-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
|
||||
disabled ? '!opacity-50 !cursor-not-allowed' : '',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
circleStyle[size],
|
||||
enabled ? translateLeft[size] : 'translate-x-0',
|
||||
'pointer-events-none inline-block transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
)}
|
||||
/>
|
||||
</OriginalSwitch>
|
||||
)
|
||||
}
|
||||
export default React.memo(Switch)
|
||||
37
web/app/components/base/tab-header/index.tsx
Normal file
37
web/app/components/base/tab-header/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
import React, { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
|
||||
import s from './style.module.css'
|
||||
|
||||
export interface ITabHeaderProps {
|
||||
items: {
|
||||
id: string
|
||||
name: string
|
||||
extra?: React.ReactNode
|
||||
}[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const TabHeader: FC<ITabHeaderProps> = ({
|
||||
items,
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex space-x-4 border-b border-gray-200 '>
|
||||
{items.map(({ id, name, extra }) => (
|
||||
<div
|
||||
key={id}
|
||||
className={cn(id === value ? `${s.itemActive} text-gray-900` : 'text-gray-500', 'relative flex items-center pb-1.5 leading-6 cursor-pointer')}
|
||||
onClick={() => onChange(id)}
|
||||
>
|
||||
<div className='text-base font-semibold'>{name}</div>
|
||||
{extra ? extra : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TabHeader)
|
||||
9
web/app/components/base/tab-header/style.module.css
Normal file
9
web/app/components/base/tab-header/style.module.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.itemActive::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #155EEF;
|
||||
}
|
||||
42
web/app/components/base/tag/index.tsx
Normal file
42
web/app/components/base/tag/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export type ITagProps = {
|
||||
children: string | React.ReactNode
|
||||
color?: keyof typeof COLOR_MAP
|
||||
className?: string
|
||||
bordered?: boolean
|
||||
hideBg?: boolean
|
||||
}
|
||||
|
||||
const COLOR_MAP = {
|
||||
green: {
|
||||
text: 'text-green-800',
|
||||
bg: 'bg-green-100',
|
||||
},
|
||||
yellow: {
|
||||
text: 'text-yellow-800',
|
||||
bg: 'bg-yellow-100',
|
||||
},
|
||||
red: {
|
||||
text: 'text-red-800',
|
||||
bg: 'bg-red-100',
|
||||
},
|
||||
gray: {
|
||||
text: 'text-gray-800',
|
||||
bg: 'bg-gray-100',
|
||||
},
|
||||
}
|
||||
|
||||
export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) {
|
||||
return (
|
||||
<div className={
|
||||
classNames('px-2.5 py-px text-xs leading-5 rounded-md inline-flex items-center flex-shrink-0',
|
||||
COLOR_MAP[color] ? `${COLOR_MAP[color].text} ${COLOR_MAP[color].bg}` : '',
|
||||
bordered ? 'border-[1px]' : '',
|
||||
hideBg ? 'bg-opacity-0' : '',
|
||||
className)} >
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
web/app/components/base/toast/index.tsx
Normal file
131
web/app/components/base/toast/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
import classNames from 'classnames'
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
export type IToastProps = {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
duration?: number
|
||||
message: string
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
type IToastContext = {
|
||||
notify: (props: IToastProps) => void
|
||||
}
|
||||
const defaultDuring = 3000
|
||||
|
||||
export const ToastContext = createContext<IToastContext>({} as IToastContext)
|
||||
const Toast = ({
|
||||
type = 'info',
|
||||
duration,
|
||||
message,
|
||||
children,
|
||||
}: IToastProps) => {
|
||||
// sometimes message is react node array. Not handle it.
|
||||
if (typeof message !== 'string') {
|
||||
return null
|
||||
}
|
||||
return <div className={classNames(
|
||||
'fixed rounded-md p-4 my-4 mx-8 z-50',
|
||||
'top-0',
|
||||
'right-0',
|
||||
type === 'success' ? 'bg-green-50' : '',
|
||||
type === 'error' ? 'bg-red-50' : '',
|
||||
type === 'warning' ? 'bg-yellow-50' : '',
|
||||
type === 'info' ? 'bg-blue-50' : '',
|
||||
)}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
{type === 'success' && <CheckCircleIcon className="w-5 h-5 text-green-400" aria-hidden="true" />}
|
||||
{type === 'error' && <XCircleIcon className="w-5 h-5 text-red-400" aria-hidden="true" />}
|
||||
{type === 'warning' && <ExclamationTriangleIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />}
|
||||
{type === 'info' && <InformationCircleIcon className="w-5 h-5 text-blue-400" aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className={
|
||||
classNames(
|
||||
'text-sm font-medium',
|
||||
type === 'success' ? 'text-green-800' : '',
|
||||
type === 'error' ? 'text-red-800' : '',
|
||||
type === 'warning' ? 'text-yellow-800' : '',
|
||||
type === 'info' ? 'text-blue-800' : '',
|
||||
)
|
||||
}>{message}</h3>
|
||||
{children && <div className={
|
||||
classNames(
|
||||
'mt-2 text-sm',
|
||||
type === 'success' ? 'text-green-700' : '',
|
||||
type === 'error' ? 'text-red-700' : '',
|
||||
type === 'warning' ? 'text-yellow-700' : '',
|
||||
type === 'info' ? 'text-blue-700' : '',
|
||||
)
|
||||
}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const ToastProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const placeholder: IToastProps = {
|
||||
type: 'info',
|
||||
message: 'Toast message',
|
||||
duration: 3000,
|
||||
}
|
||||
const [params, setParams] = React.useState<IToastProps>(placeholder)
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
setTimeout(() => {
|
||||
setMounted(false)
|
||||
}, params.duration || defaultDuring)
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
return <ToastContext.Provider value={{
|
||||
notify: (props) => {
|
||||
setMounted(true)
|
||||
setParams(props)
|
||||
},
|
||||
}}>
|
||||
{mounted && <Toast {...params} />}
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
}
|
||||
|
||||
Toast.notify = ({
|
||||
type,
|
||||
message,
|
||||
duration,
|
||||
}: Pick<IToastProps, 'type' | 'message' | 'duration'>) => {
|
||||
if (typeof window === 'object') {
|
||||
const holder = document.createElement('div')
|
||||
const root = createRoot(holder)
|
||||
|
||||
root.render(<Toast type={type} message={message} duration={duration} />)
|
||||
document.body.appendChild(holder)
|
||||
setTimeout(() => {
|
||||
if (holder)
|
||||
holder.remove()
|
||||
}, duration || defaultDuring)
|
||||
}
|
||||
}
|
||||
|
||||
export default Toast
|
||||
44
web/app/components/base/toast/style.module.css
Normal file
44
web/app/components/base/toast/style.module.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.toast {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
z-index: 99999999;
|
||||
width: 1.84rem;
|
||||
height: 1.80rem;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
background: #000000;
|
||||
box-shadow: 0 -.04rem .1rem 1px rgba(255, 255, 255, 0.1);
|
||||
border-radius: .1rem .1rem .1rem .1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: .2rem;
|
||||
height: .4rem;
|
||||
background: center center no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* .success {
|
||||
background-image: url('./icons/success.svg');
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-image: url('./icons/warning.svg');
|
||||
}
|
||||
|
||||
.error {
|
||||
background-image: url('./icons/error.svg');
|
||||
} */
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
font-size: .2rem;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
49
web/app/components/base/tooltip/index.tsx
Normal file
49
web/app/components/base/tooltip/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
import classNames from 'classnames'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { Tooltip as ReactTooltip } from 'react-tooltip' // fixed version to 5.8.3 https://github.com/ReactTooltip/react-tooltip/issues/972
|
||||
import 'react-tooltip/dist/react-tooltip.css'
|
||||
|
||||
type TooltipProps = {
|
||||
selector: string
|
||||
content?: string
|
||||
disabled?: boolean
|
||||
htmlContent?: React.ReactNode
|
||||
className?: string // This should use !impornant to override the default styles eg: '!bg-white'
|
||||
position?: 'top' | 'right' | 'bottom' | 'left'
|
||||
clickable?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Tooltip: FC<TooltipProps> = ({
|
||||
selector,
|
||||
content,
|
||||
disabled,
|
||||
position = 'top',
|
||||
children,
|
||||
htmlContent,
|
||||
className,
|
||||
clickable,
|
||||
}) => {
|
||||
return (
|
||||
<div className='tooltip-container'>
|
||||
{React.cloneElement(children as React.ReactElement, {
|
||||
'data-tooltip-id': selector,
|
||||
})
|
||||
}
|
||||
<ReactTooltip
|
||||
id={selector}
|
||||
content={content}
|
||||
className={classNames('!bg-white !text-xs !font-normal !text-gray-700 !shadow-lg !opacity-100', className)}
|
||||
place={position}
|
||||
clickable={clickable}
|
||||
isOpen={disabled ? false : undefined}
|
||||
>
|
||||
{htmlContent && htmlContent}
|
||||
</ReactTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
Reference in New Issue
Block a user