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

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

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

View 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

View 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

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

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

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

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

View 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

View 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

View 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

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

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

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

View 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

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

View 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

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

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

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

View 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

View File

@@ -0,0 +1,3 @@
.pagination li {
list-style: none;
}

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

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
'use client'
import { createContext } from 'use-context-selector'
const RadioGroupContext = createContext<any>(null)
export default RadioGroupContext

View 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

View File

@@ -0,0 +1,13 @@
.container {
padding: 4px;
border-radius: 4px;
}
.label {
position: relative;
margin-right: 3px;
}
.label:last-child {
margin-right: 0;
}

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

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

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

View 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

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

View 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

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

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

View File

@@ -0,0 +1,9 @@
.itemActive::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 2px;
background-color: #155EEF;
}

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

View 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

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

View 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