parent
a0c689c273
commit
fbf31b5d52
@ -0,0 +1,39 @@
|
|||||||
|
"""app and site icon type
|
||||||
|
|
||||||
|
Revision ID: a6be81136580
|
||||||
|
Revises: 8782057ff0dc
|
||||||
|
Create Date: 2024-08-15 10:01:24.697888
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
import models as models
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a6be81136580'
|
||||||
|
down_revision = '8782057ff0dc'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('apps', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('icon_type', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('sites', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('icon_type', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('sites', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('icon_type')
|
||||||
|
|
||||||
|
with op.batch_alter_table('apps', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('icon_type')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ChangeEvent, FC } from 'react'
|
||||||
|
import { createRef, useEffect, useState } from 'react'
|
||||||
|
import type { Area } from 'react-easy-crop'
|
||||||
|
import Cropper from 'react-easy-crop'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||||
|
import { useDraggableUploader } from './hooks'
|
||||||
|
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
|
||||||
|
|
||||||
|
type UploaderProps = {
|
||||||
|
className?: string
|
||||||
|
onImageCropped?: (tempUrl: string, croppedAreaPixels: Area, fileName: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Uploader: FC<UploaderProps> = ({
|
||||||
|
className,
|
||||||
|
onImageCropped,
|
||||||
|
}) => {
|
||||||
|
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (inputImage)
|
||||||
|
URL.revokeObjectURL(inputImage.url)
|
||||||
|
}
|
||||||
|
}, [inputImage])
|
||||||
|
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||||
|
const [zoom, setZoom] = useState(1)
|
||||||
|
|
||||||
|
const onCropComplete = async (_: Area, croppedAreaPixels: Area) => {
|
||||||
|
if (!inputImage)
|
||||||
|
return
|
||||||
|
onImageCropped?.(inputImage.url, croppedAreaPixels, inputImage.file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLocalFileInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file)
|
||||||
|
setInputImage({ file, url: URL.createObjectURL(file) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDragActive,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
} = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) }))
|
||||||
|
|
||||||
|
const inputRef = createRef<HTMLInputElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(className, 'w-full px-3 py-1.5')}>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isDragActive && 'border-primary-600',
|
||||||
|
'relative aspect-square bg-gray-50 border-[1.5px] border-gray-200 border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!inputImage
|
||||||
|
? <>
|
||||||
|
<ImagePlus className="w-[30px] h-[30px] mb-3 pointer-events-none" />
|
||||||
|
<div className="text-sm font-medium mb-[2px]">
|
||||||
|
<span className="pointer-events-none">Drop your image here, or </span>
|
||||||
|
<button className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>browse</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef} type="file" className="hidden"
|
||||||
|
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||||
|
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
|
||||||
|
onChange={handleLocalFileInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs pointer-events-none">Supports PNG, JPG, JPEG, WEBP and GIF</div>
|
||||||
|
</>
|
||||||
|
: <Cropper
|
||||||
|
image={inputImage.url}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={1}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Uploader
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
|
||||||
|
const [isDragActive, setIsDragActive] = useState(false)
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragActive(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragActive(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent<T>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragActive(false)
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
|
||||||
|
if (!file)
|
||||||
|
return
|
||||||
|
|
||||||
|
setImageFn(file)
|
||||||
|
}, [setImageFn])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
isDragActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { Area } from 'react-easy-crop'
|
||||||
|
import Modal from '../modal'
|
||||||
|
import Divider from '../divider'
|
||||||
|
import Button from '../button'
|
||||||
|
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||||
|
import { useLocalFileUploader } from '../image-uploader/hooks'
|
||||||
|
import EmojiPickerInner from '../emoji-picker/Inner'
|
||||||
|
import Uploader from './Uploader'
|
||||||
|
import s from './style.module.css'
|
||||||
|
import getCroppedImg from './utils'
|
||||||
|
import type { AppIconType, ImageFile } from '@/types/app'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||||
|
export type AppIconEmojiSelection = {
|
||||||
|
type: 'emoji'
|
||||||
|
icon: string
|
||||||
|
background: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppIconImageSelection = {
|
||||||
|
type: 'image'
|
||||||
|
fileId: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
|
||||||
|
|
||||||
|
type AppIconPickerProps = {
|
||||||
|
onSelect?: (payload: AppIconSelection) => void
|
||||||
|
onClose?: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
|
||||||
|
{ key: 'image', label: t('app.iconPicker.image'), icon: <ImagePlus /> },
|
||||||
|
]
|
||||||
|
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
|
||||||
|
|
||||||
|
const [emoji, setEmoji] = useState<{ emoji: string; background: string }>()
|
||||||
|
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
|
||||||
|
setEmoji({ emoji, background })
|
||||||
|
}, [setEmoji])
|
||||||
|
|
||||||
|
const [uploading, setUploading] = useState<boolean>()
|
||||||
|
|
||||||
|
const { handleLocalFileUpload } = useLocalFileUploader({
|
||||||
|
limit: 3,
|
||||||
|
disabled: false,
|
||||||
|
onUpload: (imageFile: ImageFile) => {
|
||||||
|
if (imageFile.fileId) {
|
||||||
|
setUploading(false)
|
||||||
|
onSelect?.({
|
||||||
|
type: 'image',
|
||||||
|
fileId: imageFile.fileId,
|
||||||
|
url: imageFile.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [imageCropInfo, setImageCropInfo] = useState<{ tempUrl: string; croppedAreaPixels: Area; fileName: string }>()
|
||||||
|
const handleImageCropped = async (tempUrl: string, croppedAreaPixels: Area, fileName: string) => {
|
||||||
|
setImageCropInfo({ tempUrl, croppedAreaPixels, fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = async () => {
|
||||||
|
if (activeTab === 'emoji') {
|
||||||
|
if (emoji) {
|
||||||
|
onSelect?.({
|
||||||
|
type: 'emoji',
|
||||||
|
icon: emoji.emoji,
|
||||||
|
background: emoji.background,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!imageCropInfo)
|
||||||
|
return
|
||||||
|
setUploading(true)
|
||||||
|
const blob = await getCroppedImg(imageCropInfo.tempUrl, imageCropInfo.croppedAreaPixels)
|
||||||
|
const file = new File([blob], imageCropInfo.fileName, { type: blob.type })
|
||||||
|
handleLocalFileUpload(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal
|
||||||
|
onClose={() => { }}
|
||||||
|
isShow
|
||||||
|
closable={false}
|
||||||
|
wrapperClassName={className}
|
||||||
|
className={cn(s.container, '!w-[362px] !p-0')}
|
||||||
|
>
|
||||||
|
{!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="p-2 pb-0 w-full">
|
||||||
|
<div className='p-1 flex items-center justify-center gap-2 bg-background-body rounded-xl'>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className={`
|
||||||
|
p-2 flex-1 flex justify-center items-center h-8 rounded-xl text-sm shrink-0 font-medium
|
||||||
|
${activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active shadow-md'}
|
||||||
|
`}
|
||||||
|
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<Divider className='m-0' />
|
||||||
|
|
||||||
|
<EmojiPickerInner className={activeTab === 'emoji' ? 'block' : 'hidden'} onSelect={handleSelectEmoji} />
|
||||||
|
<Uploader className={activeTab === 'image' ? 'block' : 'hidden'} onImageCropped={handleImageCropped} />
|
||||||
|
|
||||||
|
<Divider className='m-0' />
|
||||||
|
<div className='w-full flex items-center justify-center p-3 gap-2'>
|
||||||
|
<Button className='w-full' onClick={() => onClose?.()}>
|
||||||
|
{t('app.iconPicker.cancel')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="primary" className='w-full' disabled={uploading} loading={uploading} onClick={handleSelect}>
|
||||||
|
{t('app.iconPicker.ok')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppIconPicker
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 362px;
|
||||||
|
max-height: 552px;
|
||||||
|
|
||||||
|
border: 0.5px solid #EAECF0;
|
||||||
|
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
export const createImage = (url: string) =>
|
||||||
|
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.addEventListener('load', () => resolve(image))
|
||||||
|
image.addEventListener('error', error => reject(error))
|
||||||
|
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
|
||||||
|
image.src = url
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getRadianAngle(degreeValue: number) {
|
||||||
|
return (degreeValue * Math.PI) / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the new bounding area of a rotated rectangle.
|
||||||
|
*/
|
||||||
|
export function rotateSize(width: number, height: number, rotation: number) {
|
||||||
|
const rotRad = getRadianAngle(rotation)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width:
|
||||||
|
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
|
||||||
|
height:
|
||||||
|
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
|
||||||
|
*/
|
||||||
|
export default async function getCroppedImg(
|
||||||
|
imageSrc: string,
|
||||||
|
pixelCrop: { x: number; y: number; width: number; height: number },
|
||||||
|
rotation = 0,
|
||||||
|
flip = { horizontal: false, vertical: false },
|
||||||
|
): Promise<Blob> {
|
||||||
|
const image = await createImage(imageSrc)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error('Could not create a canvas context')
|
||||||
|
|
||||||
|
const rotRad = getRadianAngle(rotation)
|
||||||
|
|
||||||
|
// calculate bounding box of the rotated image
|
||||||
|
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
rotation,
|
||||||
|
)
|
||||||
|
|
||||||
|
// set canvas size to match the bounding box
|
||||||
|
canvas.width = bBoxWidth
|
||||||
|
canvas.height = bBoxHeight
|
||||||
|
|
||||||
|
// translate canvas context to a central location to allow rotating and flipping around the center
|
||||||
|
ctx.translate(bBoxWidth / 2, bBoxHeight / 2)
|
||||||
|
ctx.rotate(rotRad)
|
||||||
|
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1)
|
||||||
|
ctx.translate(-image.width / 2, -image.height / 2)
|
||||||
|
|
||||||
|
// draw rotated image
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
|
||||||
|
const croppedCanvas = document.createElement('canvas')
|
||||||
|
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!croppedCtx)
|
||||||
|
throw new Error('Could not create a canvas context')
|
||||||
|
|
||||||
|
// Set the size of the cropped canvas
|
||||||
|
croppedCanvas.width = pixelCrop.width
|
||||||
|
croppedCanvas.height = pixelCrop.height
|
||||||
|
|
||||||
|
// Draw the cropped image onto the new canvas
|
||||||
|
croppedCtx.drawImage(
|
||||||
|
canvas,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
croppedCanvas.toBlob((file) => {
|
||||||
|
if (file)
|
||||||
|
resolve(file)
|
||||||
|
else
|
||||||
|
reject(new Error('Could not create a blob'))
|
||||||
|
}, 'image/jpeg')
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
'use client'
|
||||||
|
import type { ChangeEvent, FC } from 'react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import type { EmojiMartData } from '@emoji-mart/data'
|
||||||
|
import { init } from 'emoji-mart'
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import Divider from '@/app/components/base/divider'
|
||||||
|
import { searchEmoji } from '@/utils/emoji'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'em-emoji': React.DetailedHTMLProps< React.HTMLAttributes<HTMLElement>, HTMLElement >
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init({ data })
|
||||||
|
|
||||||
|
const backgroundColors = [
|
||||||
|
'#FFEAD5',
|
||||||
|
'#E4FBCC',
|
||||||
|
'#D3F8DF',
|
||||||
|
'#E0F2FE',
|
||||||
|
|
||||||
|
'#E0EAFF',
|
||||||
|
'#EFF1F5',
|
||||||
|
'#FBE8FF',
|
||||||
|
'#FCE7F6',
|
||||||
|
|
||||||
|
'#FEF7C3',
|
||||||
|
'#E6F4D7',
|
||||||
|
'#D5F5F6',
|
||||||
|
'#D1E9FF',
|
||||||
|
|
||||||
|
'#D1E0FF',
|
||||||
|
'#D5D9EB',
|
||||||
|
'#ECE9FE',
|
||||||
|
'#FFE4E8',
|
||||||
|
]
|
||||||
|
|
||||||
|
type IEmojiPickerInnerProps = {
|
||||||
|
emoji?: string
|
||||||
|
background?: string
|
||||||
|
onSelect?: (emoji: string, background: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { categories } = data as EmojiMartData
|
||||||
|
const [selectedEmoji, setSelectedEmoji] = useState('')
|
||||||
|
const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0])
|
||||||
|
|
||||||
|
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedEmoji && selectedBackground)
|
||||||
|
onSelect?.(selectedEmoji, selectedBackground)
|
||||||
|
}, [onSelect, selectedEmoji, selectedBackground])
|
||||||
|
|
||||||
|
return <div className={cn(className)}>
|
||||||
|
<div className='flex flex-col items-center w-full px-3'>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="search"
|
||||||
|
className='block w-full h-10 px-3 pl-10 text-sm font-normal bg-gray-100 rounded-lg'
|
||||||
|
placeholder="Search emojis..."
|
||||||
|
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.value === '') {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setIsSearching(true)
|
||||||
|
const emojis = await searchEmoji(e.target.value)
|
||||||
|
setSearchedEmojis(emojis)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider className='m-0 mb-3' />
|
||||||
|
|
||||||
|
<div className="w-full max-h-[200px] overflow-x-hidden overflow-y-auto px-3">
|
||||||
|
{isSearching && <>
|
||||||
|
<div key={'category-search'} className='flex flex-col'>
|
||||||
|
<p className='font-medium uppercase text-xs text-[#101828] mb-1'>Search</p>
|
||||||
|
<div className='w-full h-full grid grid-cols-8 gap-1'>
|
||||||
|
{searchedEmojis.map((emoji: string, index: number) => {
|
||||||
|
return <div
|
||||||
|
key={`emoji-search-${index}`}
|
||||||
|
className='inline-flex w-10 h-10 rounded-lg items-center justify-center'
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmoji(emoji)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='cursor-pointer w-8 h-8 p-1 flex items-center justify-center rounded-lg hover:ring-1 ring-offset-1 ring-gray-300'>
|
||||||
|
<em-emoji id={emoji} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{categories.map((category, index: number) => {
|
||||||
|
return <div key={`category-${index}`} className='flex flex-col'>
|
||||||
|
<p className='font-medium uppercase text-xs text-[#101828] mb-1'>{category.id}</p>
|
||||||
|
<div className='w-full h-full grid grid-cols-8 gap-1'>
|
||||||
|
{category.emojis.map((emoji, index: number) => {
|
||||||
|
return <div
|
||||||
|
key={`emoji-${index}`}
|
||||||
|
className='inline-flex w-10 h-10 rounded-lg items-center justify-center'
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmoji(emoji)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='cursor-pointer w-8 h-8 p-1 flex items-center justify-center rounded-lg hover:ring-1 ring-offset-1 ring-gray-300'>
|
||||||
|
<em-emoji id={emoji} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Select */}
|
||||||
|
<div className={cn('p-3 pb-0', selectedEmoji === '' ? 'opacity-25' : '')}>
|
||||||
|
<p className='font-medium uppercase text-xs text-[#101828] mb-2'>Choose Style</p>
|
||||||
|
<div className='w-full h-full grid grid-cols-8 gap-1'>
|
||||||
|
{backgroundColors.map((color) => {
|
||||||
|
return <div
|
||||||
|
key={color}
|
||||||
|
className={
|
||||||
|
cn(
|
||||||
|
'cursor-pointer',
|
||||||
|
'hover:ring-1 ring-offset-1',
|
||||||
|
'inline-flex w-10 h-10 rounded-lg items-center justify-center',
|
||||||
|
color === selectedBackground ? 'ring-1 ring-gray-300' : '',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBackground(color)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 p-1 flex items-center justify-center rounded-lg',
|
||||||
|
)
|
||||||
|
} style={{ background: color }}>
|
||||||
|
{selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
export default EmojiPickerInner
|
||||||
Loading…
Reference in New Issue