feat: init

This commit is contained in:
Joel
2023-04-14 20:35:08 +08:00
parent 8acd8f6fbd
commit 97d3a6277d
77 changed files with 5299 additions and 0 deletions

View File

@ -0,0 +1,36 @@
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
}
const AppIcon: FC<AppIconProps> = ({
size = 'medium',
rounded = false,
background,
className,
}) => {
return (
<span
className={classNames(
style.appIcon,
size !== 'medium' && style[size],
rounded && style.rounded,
className ?? '',
)}
style={{
background,
}}
>
🤖
</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,73 @@
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) {
ref.current.setSelectionRange(value.length, value.length)
ref.current.focus()
return true
}
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,44 @@
import type { FC, MouseEventHandler } from 'react'
import React from 'react'
import Spinner from '@/app/components/base/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-600/75 cursor-not-allowed text-white' : 'bg-primary-600 hover:bg-primary-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={`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,31 @@
import React from 'react'
import './style.css'
type 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,45 @@
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'
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,216 @@
'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'
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
items?: Item[]
defaultValue?: number | string
disabled?: boolean
onSelect: (value: Item) => void
allowSearch?: boolean
bgClassName?: 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,
items = defaultItems,
defaultValue = 1,
disabled = false,
onSelect,
}) => {
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">
<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="block truncate text-left">{selectedItem?.name}</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,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,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,43 @@
.toast {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
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,46 @@
'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
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,
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}
>
{htmlContent && htmlContent}
</ReactTooltip>
</div>
)
}
export default Tooltip