dify/web/app/components/base/app-icon-picker/index.tsx
Stephen Zhou a84c2d36a3
style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00

248 lines
6.9 KiB
TypeScript

import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiImageCircleAiLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import Divider from '../divider'
import { defaultEmojiBackground } from '../emoji-picker/constants'
import EmojiPickerInner from '../emoji-picker/Inner'
import { useLocalFileUploader } from '../image-uploader/hooks'
import ImageInput from './ImageInput'
import s from './style.module.css'
import getCroppedImg from './utils'
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 = {
open: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void
enableImageUpload?: boolean
initialEmoji?: {
icon: string
background?: string | null
}
className?: string
}
function AppIconPicker({
open,
onOpenChange,
onSelect,
enableImageUpload = true,
initialEmoji,
className,
}: AppIconPickerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<AppIconPickerContent
key={`${initialEmoji?.icon ?? ''}:${initialEmoji?.background ?? ''}`}
initialEmoji={initialEmoji}
enableImageUpload={enableImageUpload}
className={className}
onOpenChange={onOpenChange}
onSelect={onSelect}
/>
) : null}
</Dialog>
)
}
type AppIconPickerContentProps = {
className?: string
initialEmoji?: {
icon: string
background?: string | null
}
enableImageUpload: boolean
onOpenChange: (open: boolean) => void
onSelect?: (payload: AppIconSelection) => void
}
function AppIconPickerContent({
className,
initialEmoji,
enableImageUpload,
onOpenChange,
onSelect,
}: AppIconPickerContentProps) {
const { t } = useTranslation()
const tabs = [
{
key: 'emoji',
label: t(($) => $['iconPicker.emoji'], { ns: 'app' }),
icon: <span className="text-lg">🤖</span>,
},
{
key: 'image',
label: t(($) => $['iconPicker.image'], { ns: 'app' }),
icon: <RiImageCircleAiLine className="size-4" />,
},
]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
const showImageUpload = enableImageUpload && !DISABLE_UPLOAD_IMAGE_AS_ICON
const [emoji, setEmoji] = useState<{ emoji: string; background: string } | undefined>(() => {
if (!initialEmoji?.icon) return undefined
return {
emoji: initialEmoji.icon,
background: initialEmoji.background ?? defaultEmojiBackground,
}
})
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,
})
onOpenChange(false)
}
},
})
type InputImageInfo =
| { file: File }
| { tempUrl: string; croppedAreaPixels: Area; fileName: string }
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
const handleImageInput: OnImageInput = async (
isCropped: boolean,
fileOrTempUrl: string | File,
croppedAreaPixels?: Area,
fileName?: string,
) => {
setInputImageInfo(
isCropped
? {
tempUrl: fileOrTempUrl as string,
croppedAreaPixels: croppedAreaPixels!,
fileName: fileName!,
}
: { file: fileOrTempUrl as File },
)
}
const handleSelect = async () => {
if (activeTab === 'emoji') {
if (emoji) {
onSelect?.({
type: 'emoji',
icon: emoji.emoji,
background: emoji.background,
})
onOpenChange(false)
}
} else {
if (!inputImageInfo) return
setUploading(true)
if ('file' in inputImageInfo) {
handleLocalFileUpload(inputImageInfo.file)
return
}
const blob = await getCroppedImg(
inputImageInfo.tempUrl,
inputImageInfo.croppedAreaPixels,
inputImageInfo.fileName,
)
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
handleLocalFileUpload(file)
}
}
return (
<DialogContent
className={cn(
'w-full overflow-hidden! border-none text-left align-middle',
s.container,
'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!',
className,
)}
>
<DialogTitle className="sr-only">
{t(($) => $['iconPicker.emoji'], { ns: 'app' })}
</DialogTitle>
{showImageUpload && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map((tab) => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key &&
'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon} &nbsp;
{tab.label}
</button>
))}
</div>
</div>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={(emoji, background) => setEmoji({ emoji, background })}
/>
)}
{activeTab === 'image' && (
<ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />
)}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t(($) => $['iconPicker.cancel'], { ns: 'app' })}
</Button>
<Button
variant="primary"
className="w-full"
disabled={uploading}
loading={uploading}
onClick={handleSelect}
>
{t(($) => $['iconPicker.ok'], { ns: 'app' })}
</Button>
</div>
</DialogContent>
)
}
export default AppIconPicker