feat: custom app icon (#7196)

Co-authored-by: crazywoola <427733928@qq.com>
This commit is contained in:
Hash Brown
2024-08-19 09:16:33 +08:00
committed by GitHub
parent a0c689c273
commit fbf31b5d52
65 changed files with 1068 additions and 352 deletions

View File

@@ -75,6 +75,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
icon_background,
description,
@@ -83,6 +84,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
await updateAppInfo({
appID: app.id,
name,
icon_type,
icon,
icon_background,
description,
@@ -101,11 +103,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}
}, [app.id, mutateApps, notify, onRefresh, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon, icon_background }) => {
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
try {
const newApp = await copyApp({
appID: app.id,
name,
icon_type,
icon,
icon_background,
mode: app.mode,
@@ -258,8 +261,10 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<div className='relative shrink-0'>
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className='absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
{app.mode === 'advanced-chat' && (
@@ -360,9 +365,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{showEditModal && (
<EditAppModal
isEditModal
appName={app.name}
appIconType={app.icon_type}
appIcon={app.icon}
appIconBackground={app.icon_background}
appName={app.name}
appIconUrl={app.icon_url}
appDescription={app.description}
show={showEditModal}
onConfirm={onEdit}
@@ -372,8 +379,10 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{showDuplicateModal && (
<DuplicateAppModal
appName={app.name}
icon_type={app.icon_type}
icon={app.icon}
icon_background={app.icon_background}
icon_url={app.icon_url}
show={showDuplicateModal}
onConfirm={onCopy}
onHide={() => setShowDuplicateModal(false)}

View File

@@ -60,7 +60,7 @@ const LikedItem = ({
return (
<Link className={classNames(s.itemWrapper, 'px-2', isMobile && 'justify-center')} href={`/app/${detail?.id}/overview`}>
<div className={classNames(s.iconWrapper, 'mr-0')}>
<AppIcon size='tiny' icon={detail?.icon} background={detail?.icon_background} />
<AppIcon size='tiny' iconType={detail.icon_type} icon={detail.icon} background={detail.icon_background} imageUrl={detail.icon_url} />
{type === 'app' && (
<span className='absolute bottom-[-2px] right-[-2px] w-3.5 h-3.5 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
{detail.mode === 'advanced-chat' && (

View File

@@ -59,6 +59,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
icon_background,
description,
@@ -69,6 +70,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
const app = await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
description,
@@ -86,13 +88,14 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
}
}, [appDetail, mutateApps, notify, setAppDetail, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon, icon_background }) => {
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
return
try {
const newApp = await copyApp({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
mode: appDetail.mode,
@@ -194,7 +197,13 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
>
<div className={cn('flex p-1 rounded-lg', open && 'bg-gray-100', isCurrentWorkspaceEditor && 'hover:bg-gray-100 cursor-pointer')}>
<div className='relative shrink-0 mr-2'>
<AppIcon size={expand ? 'large' : 'small'} icon={appDetail.icon} background={appDetail.icon_background} />
<AppIcon
size={expand ? 'large' : 'small'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<span className={cn(
'absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm',
!expand && '!w-3.5 !h-3.5 !bottom-[-2px] !right-[-2px]',
@@ -257,7 +266,13 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
{/* header */}
<div className={cn('flex pl-4 pt-3 pr-3', !appDetail.description && 'pb-2')}>
<div className='relative shrink-0 mr-2'>
<AppIcon size="large" icon={appDetail.icon} background={appDetail.icon_background} />
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<span className='absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
{appDetail.mode === 'advanced-chat' && (
<ChatBot className='w-3 h-3 text-[#1570EF]' />
@@ -402,9 +417,11 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
{showEditModal && (
<CreateAppModal
isEditModal
appName={appDetail.name}
appIconType={appDetail.icon_type}
appIcon={appDetail.icon}
appIconBackground={appDetail.icon_background}
appName={appDetail.name}
appIconUrl={appDetail.icon_url}
appDescription={appDetail.description}
show={showEditModal}
onConfirm={onEdit}
@@ -414,8 +431,10 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
{showDuplicateModal && (
<DuplicateAppModal
appName={appDetail.name}
icon_type={appDetail.icon_type}
icon={appDetail.icon}
icon_background={appDetail.icon_background}
icon_url={appDetail.icon_url}
show={showDuplicateModal}
onConfirm={onCopy}
onHide={() => setShowDuplicateModal(false)}

View File

@@ -8,6 +8,8 @@ import {
} from '@remixicon/react'
import { useRouter } from 'next/navigation'
import { useContext, useContextSelector } from 'use-context-selector'
import AppIconPicker from '../../base/app-icon-picker'
import type { AppIconSelection } from '../../base/app-icon-picker'
import s from './style.module.css'
import cn from '@/utils/classnames'
import AppsContext, { useAppContext } from '@/context/app-context'
@@ -18,7 +20,6 @@ import { createApp } from '@/service/apps'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { AiText, ChatBot, CuteRobote } from '@/app/components/base/icons/src/vender/solid/communication'
import { Route } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
@@ -40,8 +41,8 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
const [appMode, setAppMode] = useState<AppMode>('chat')
const [showChatBotType, setShowChatBotType] = useState<boolean>(true)
const [emoji, setEmoji] = useState({ icon: '🤖', icon_background: '#FFEAD5' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
@@ -66,8 +67,9 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
const app = await createApp({
name,
description,
icon: emoji.icon,
icon_background: emoji.icon_background,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
mode: appMode,
})
notify({ type: 'success', message: t('app.newApp.appCreated') })
@@ -81,7 +83,7 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
}
isCreatingRef.current = false
}, [name, notify, t, appMode, emoji.icon, emoji.icon_background, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor])
}, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor])
return (
<Modal
@@ -269,7 +271,14 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
<div className='pt-2 px-8'>
<div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.newApp.captionName')}</div>
<div className='flex items-center justify-between space-x-2'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
<AppIcon
iconType={appIcon.type}
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
size='large' className='cursor-pointer'
onClick={() => { setShowAppIconPicker(true) }}
/>
<input
value={name}
onChange={e => setName(e.target.value)}
@@ -277,14 +286,13 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
className='grow h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs'
/>
</div>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setEmoji({ icon: '🤖', icon_background: '#FFEAD5' })
setShowEmojiPicker(false)
setShowAppIconPicker(false)
}}
/>}
</div>

View File

@@ -1,32 +1,39 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIconPicker from '../../base/app-icon-picker'
import s from './style.module.css'
import cn from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import { useProviderContext } from '@/context/provider-context'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import type { AppIconType } from '@/types/app'
export type DuplicateAppModalProps = {
appName: string
icon_type: AppIconType | null
icon: string
icon_background: string
icon_background?: string | null
icon_url?: string | null
show: boolean
onConfirm: (info: {
name: string
icon_type: AppIconType
icon: string
icon_background: string
icon_background?: string | null
}) => Promise<void>
onHide: () => void
}
const DuplicateAppModal = ({
appName,
icon_type,
icon,
icon_background,
icon_url,
show = false,
onConfirm,
onHide,
@@ -35,8 +42,12 @@ const DuplicateAppModal = ({
const [name, setName] = React.useState(appName)
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [emoji, setEmoji] = useState({ icon, icon_background })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState(
icon_type === 'image'
? { type: 'image' as const, url: icon_url, fileId: icon }
: { type: 'emoji' as const, icon, background: icon_background },
)
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
@@ -48,7 +59,9 @@ const DuplicateAppModal = ({
}
onConfirm({
name,
...emoji,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
})
onHide()
}
@@ -65,7 +78,15 @@ const DuplicateAppModal = ({
<div className={s.content}>
<div className={s.subTitle}>{t('explore.appCustomize.subTitle')}</div>
<div className='flex items-center justify-between space-x-2'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
<AppIcon
size='large'
onClick={() => { setShowAppIconPicker(true) }}
className='cursor-pointer'
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
/>
<input
value={name}
onChange={e => setName(e.target.value)}
@@ -79,14 +100,16 @@ const DuplicateAppModal = ({
<Button className='w-24' onClick={onHide}>{t('common.operation.cancel')}</Button>
</div>
</Modal>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
setAppIcon(icon_type === 'image'
? { type: 'image', url: icon_url!, fileId: icon }
: { type: 'emoji', icon, background: icon_background! })
setShowAppIconPicker(false)
}}
/>}
</>

View File

@@ -10,11 +10,11 @@ import Button from '@/app/components/base/button'
import AppIcon from '@/app/components/base/app-icon'
import { SimpleSelect } from '@/app/components/base/select'
import type { AppDetailResponse } from '@/models/app'
import type { Language } from '@/types/app'
import EmojiPicker from '@/app/components/base/emoji-picker'
import type { AppIconType, Language } from '@/types/app'
import { useToastContext } from '@/app/components/base/toast'
import { languages } from '@/i18n/language'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import AppIconPicker from '@/app/components/base/app-icon-picker'
export type ISettingsModalProps = {
isChat: boolean
@@ -35,8 +35,9 @@ export type ConfigParams = {
copyright: string
privacy_policy: string
custom_disclaimer: string
icon_type: AppIconType
icon: string
icon_background: string
icon_background?: string
show_workflow_steps: boolean
}
@@ -51,9 +52,12 @@ const SettingsModal: FC<ISettingsModalProps> = ({
}) => {
const { notify } = useToastContext()
const [isShowMore, setIsShowMore] = useState(false)
const { icon, icon_background } = appInfo
const {
title,
icon_type,
icon,
icon_background,
icon_url,
description,
chat_color_theme,
chat_color_theme_inverted,
@@ -76,9 +80,13 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const [language, setLanguage] = useState(default_language)
const [saveLoading, setSaveLoading] = useState(false)
const { t } = useTranslation()
// Emoji Picker
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [emoji, setEmoji] = useState({ icon, icon_background })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState<AppIconSelection>(
icon_type === 'image'
? { type: 'image', url: icon_url!, fileId: icon }
: { type: 'emoji', icon, background: icon_background! },
)
useEffect(() => {
setInputInfo({
@@ -92,7 +100,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
show_workflow_steps,
})
setLanguage(default_language)
setEmoji({ icon, icon_background })
setAppIcon(icon_type === 'image'
? { type: 'image', url: icon_url!, fileId: icon }
: { type: 'emoji', icon, background: icon_background! })
}, [appInfo])
const onHide = () => {
@@ -135,8 +145,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
copyright: inputInfo.copyright,
privacy_policy: inputInfo.privacyPolicy,
custom_disclaimer: inputInfo.customDisclaimer,
icon: emoji.icon,
icon_background: emoji.icon_background,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
show_workflow_steps: inputInfo.show_workflow_steps,
}
await onSave?.(params)
@@ -167,10 +178,12 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className={`mt-6 font-medium ${s.settingTitle} text-gray-900`}>{t(`${prefixSettings}.webName`)}</div>
<div className='flex mt-2'>
<AppIcon size='large'
onClick={() => { setShowEmojiPicker(true) }}
onClick={() => { setShowAppIconPicker(true) }}
className='cursor-pointer !mr-3 self-center'
icon={emoji.icon}
background={emoji.icon_background}
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
/>
<input className={`flex-grow rounded-lg h-10 box-border px-3 ${s.projectName} bg-gray-100`}
value={inputInfo.title}
@@ -250,14 +263,16 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
</div>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setEmoji({ icon: appInfo.site.icon, icon_background: appInfo.site.icon_background })
setShowEmojiPicker(false)
setAppIcon(icon_type === 'image'
? { type: 'image', url: icon_url!, fileId: icon }
: { type: 'emoji', icon, background: icon_background! })
setShowAppIconPicker(false)
}}
/>}
</Modal >

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import AppIconPicker from '../../base/app-icon-picker'
import s from './style.module.css'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
@@ -15,7 +16,6 @@ import { deleteApp, switchApp } from '@/service/apps'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import EmojiPicker from '@/app/components/base/emoji-picker'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { getRedirection } from '@/utils/app-redirection'
import type { App } from '@/types/app'
@@ -41,8 +41,13 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
const [emoji, setEmoji] = useState({ icon: appDetail.icon, icon_background: appDetail.icon_background })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState(
appDetail.icon_type === 'image'
? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon }
: { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background },
)
const [name, setName] = useState(`${appDetail.name}(copy)`)
const [removeOriginal, setRemoveOriginal] = useState<boolean>(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
@@ -52,8 +57,9 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
const { new_app_id: newAppID } = await switchApp({
appID: appDetail.id,
name,
icon: emoji.icon,
icon_background: emoji.icon_background,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
})
if (onSuccess)
onSuccess()
@@ -106,7 +112,15 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
<div className='pb-4'>
<div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.switchLabel')}</div>
<div className='flex items-center justify-between space-x-2'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
<AppIcon
size='large'
onClick={() => { setShowAppIconPicker(true) }}
className='cursor-pointer'
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
/>
<input
value={name}
onChange={e => setName(e.target.value)}
@@ -114,14 +128,16 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
className='grow h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs'
/>
</div>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setEmoji({ icon: appDetail.icon, icon_background: appDetail.icon_background })
setShowEmojiPicker(false)
setAppIcon(appDetail.icon_type === 'image'
? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon }
: { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background })
setShowAppIconPicker(false)
}}
/>}
</div>

View File

@@ -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&nbsp;</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

View File

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

View File

@@ -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} &nbsp; {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

View File

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

View File

@@ -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')
})
}

View File

@@ -1,17 +1,21 @@
import type { FC } from 'react'
'use client'
import data from '@emoji-mart/data'
import type { FC } from 'react'
import { init } from 'emoji-mart'
import data from '@emoji-mart/data'
import style from './style.module.css'
import classNames from '@/utils/classnames'
import type { AppIconType } from '@/types/app'
init({ data })
export type AppIconProps = {
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
rounded?: boolean
iconType?: AppIconType | null
icon?: string
background?: string
background?: string | null
imageUrl?: string | null
className?: string
innerIcon?: React.ReactNode
onClick?: () => void
@@ -20,28 +24,34 @@ export type AppIconProps = {
const AppIcon: FC<AppIconProps> = ({
size = 'medium',
rounded = false,
iconType,
icon,
background,
imageUrl,
className,
innerIcon,
onClick,
}) => {
return (
<span
className={classNames(
style.appIcon,
size !== 'medium' && style[size],
rounded && style.rounded,
className ?? '',
)}
style={{
background,
}}
onClick={onClick}
>
{innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />)}
</span>
const wrapperClassName = classNames(
style.appIcon,
size !== 'medium' && style[size],
rounded && style.rounded,
className ?? '',
'overflow-hidden',
)
const isValidImageIcon = iconType === 'image' && imageUrl
return <span
className={wrapperClassName}
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
onClick={onClick}
>
{isValidImageIcon
? <img src={imageUrl} className="w-full h-full" alt="app icon" />
: (innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />))
}
</span>
}
export default AppIcon

View File

@@ -1,5 +1,5 @@
.appIcon {
@apply flex items-center justify-center relative w-9 h-9 text-lg bg-teal-100 rounded-lg grow-0 shrink-0;
@apply flex items-center justify-center relative w-9 h-9 text-lg rounded-lg grow-0 shrink-0;
}
.appIcon.large {

View File

@@ -43,7 +43,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
useAppFavicon(!installedAppInfo, appInfo?.site.icon, appInfo?.site.icon_background)
useAppFavicon({
enable: !installedAppInfo,
icon_type: appInfo?.site.icon_type,
icon: appInfo?.site.icon,
icon_background: appInfo?.site.icon_background,
icon_url: appInfo?.site.icon_url,
})
const appData = useMemo(() => {
if (isInstalledApp) {
@@ -52,8 +58,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
app_id: id,
site: {
title: app.name,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
prompt_public: false,
copyright: '',
show_workflow_steps: true,

View File

@@ -67,8 +67,10 @@ const Sidebar = () => {
<AppIcon
className='mr-3'
size='small'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='py-1 text-base font-semibold text-gray-800'>
{appData?.site.title}

View File

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

View File

@@ -1,56 +1,13 @@
/* eslint-disable multiline-ternary */
'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 type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'
import EmojiPickerInner from './Inner'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
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 IEmojiPickerProps = {
isModal?: boolean
@@ -66,136 +23,43 @@ const EmojiPicker: FC<IEmojiPickerProps> = ({
className,
}) => {
const { t } = useTranslation()
const { categories } = data as EmojiMartData
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0])
const [selectedBackground, setSelectedBackground] = useState<string>()
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
const [isSearching, setIsSearching] = useState(false)
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}, [setSelectedEmoji, setSelectedBackground])
return isModal ? <Modal
onClose={() => { }}
isShow
closable={false}
wrapperClassName={className}
className={cn(s.container, '!w-[362px] !p-0')}
>
<div className='flex flex-col items-center w-full p-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 ', 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>
<Divider className='m-0' />
<div className='w-full flex items-center justify-center p-3 gap-2'>
<Button className='w-full' onClick={() => {
onClose && onClose()
}}>
{t('app.emoji.cancel')}
</Button>
<Button
disabled={selectedEmoji === ''}
variant="primary"
className='w-full'
onClick={() => {
onSelect && onSelect(selectedEmoji, selectedBackground)
return isModal
? <Modal
onClose={() => { }}
isShow
closable={false}
wrapperClassName={className}
className={cn(s.container, '!w-[362px] !p-0')}
>
<EmojiPickerInner
className="pt-3"
onSelect={handleSelectEmoji} />
<Divider className='m-0' />
<div className='w-full flex items-center justify-center p-3 gap-2'>
<Button className='w-full' onClick={() => {
onClose && onClose()
}}>
{t('app.emoji.ok')}
</Button>
</div>
</Modal> : <>
</>
{t('app.iconPicker.cancel')}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className='w-full'
onClick={() => {
onSelect && onSelect(selectedEmoji, selectedBackground!)
}}>
{t('app.iconPicker.ok')}
</Button>
</div>
</Modal>
: <></>
}
export default EmojiPicker

View File

@@ -26,7 +26,13 @@ const AppCard = ({
<div className={cn('group flex col-span-1 bg-white border-2 border-solid border-transparent rounded-lg shadow-sm min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg')}>
<div className='flex pt-[14px] px-[14px] pb-3 h-[66px] items-center gap-3 grow-0 shrink-0'>
<div className='relative shrink-0'>
<AppIcon size='large' icon={app.app.icon} background={app.app.icon_background} />
<AppIcon
size='large'
iconType={app.app.icon_type}
icon={app.app.icon}
background={app.app.icon_background}
imageUrl={app.app.icon_url}
/>
<span className='absolute bottom-[-3px] right-[-3px] w-4 h-4 p-0.5 bg-white rounded border-[0.5px] border-[rgba(0,0,0,0.02)] shadow-sm'>
{appBasicInfo.mode === 'advanced-chat' && (
<ChatBot className='w-3 h-3 text-[#1570EF]' />

View File

@@ -118,6 +118,7 @@ const Apps = ({
const [isShowCreateModal, setIsShowCreateModal] = React.useState(false)
const onCreate: CreateAppModalProps['onConfirm'] = async ({
name,
icon_type,
icon,
icon_background,
description,
@@ -129,6 +130,7 @@ const Apps = ({
const app = await importApp({
data: export_data,
name,
icon_type,
icon,
icon_background,
description,
@@ -215,8 +217,10 @@ const Apps = ({
</div>
{isShowCreateModal && (
<CreateAppModal
appIconType={currApp?.app.icon_type || 'emoji'}
appIcon={currApp?.app.icon || ''}
appIconBackground={currApp?.app.icon_background || ''}
appIconUrl={currApp?.app.icon_url}
appName={currApp?.app.name || ''}
appDescription={currApp?.app.description || ''}
show={isShowCreateModal}

View File

@@ -2,25 +2,29 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import AppIconPicker from '../../base/app-icon-picker'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import { useProviderContext } from '@/context/provider-context'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import type { AppIconType } from '@/types/app'
export type CreateAppModalProps = {
show: boolean
isEditModal?: boolean
appName: string
appDescription: string
appIconType: AppIconType | null
appIcon: string
appIconBackground: string
appIconBackground?: string | null
appIconUrl?: string | null
onConfirm: (info: {
name: string
icon_type: AppIconType
icon: string
icon_background: string
icon_background?: string
description: string
}) => Promise<void>
onHide: () => void
@@ -29,8 +33,10 @@ export type CreateAppModalProps = {
const CreateAppModal = ({
show = false,
isEditModal = false,
appIcon,
appIconType,
appIcon: _appIcon,
appIconBackground,
appIconUrl,
appName,
appDescription,
onConfirm,
@@ -39,8 +45,12 @@ const CreateAppModal = ({
const { t } = useTranslation()
const [name, setName] = React.useState(appName)
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [emoji, setEmoji] = useState({ icon: appIcon, icon_background: appIconBackground })
const [appIcon, setAppIcon] = useState(
() => appIconType === 'image'
? { type: 'image' as const, fileId: _appIcon, url: appIconUrl }
: { type: 'emoji' as const, icon: _appIcon, background: appIconBackground },
)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [description, setDescription] = useState(appDescription || '')
const { plan, enableBilling } = useProviderContext()
@@ -53,7 +63,9 @@ const CreateAppModal = ({
}
onConfirm({
name,
...emoji,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background! : undefined,
description,
})
onHide()
@@ -80,7 +92,15 @@ const CreateAppModal = ({
<div className='pt-2'>
<div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.newApp.captionName')}</div>
<div className='flex items-center justify-between space-x-2'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
<AppIcon
size='large'
onClick={() => { setShowAppIconPicker(true) }}
className='cursor-pointer'
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
/>
<input
value={name}
onChange={e => setName(e.target.value)}
@@ -106,18 +126,19 @@ const CreateAppModal = ({
<Button className='w-24' onClick={onHide}>{t('common.operation.cancel')}</Button>
</div>
</Modal>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setEmoji({ icon: appIcon, icon_background: appIconBackground })
setShowEmojiPicker(false)
setAppIcon(appIconType === 'image'
? { type: 'image' as const, url: appIconUrl, fileId: _appIcon }
: { type: 'emoji' as const, icon: _appIcon, background: appIconBackground })
setShowAppIconPicker(false)
}}
/>}
</>
)
}

View File

@@ -7,13 +7,16 @@ import s from './style.module.css'
import cn from '@/utils/classnames'
import ItemOperation from '@/app/components/explore/item-operation'
import AppIcon from '@/app/components/base/app-icon'
import type { AppIconType } from '@/types/app'
export type IAppNavItemProps = {
isMobile: boolean
name: string
id: string
icon_type: AppIconType | null
icon: string
icon_background: string
icon_url: string
isSelected: boolean
isPinned: boolean
togglePin: () => void
@@ -25,8 +28,10 @@ export default function AppNavItem({
isMobile,
name,
id,
icon_type,
icon,
icon_background,
icon_url,
isSelected,
isPinned,
togglePin,
@@ -50,11 +55,11 @@ export default function AppNavItem({
router.push(url) // use Link causes popup item always trigger jump. Can not be solved by e.stopPropagation().
}}
>
{isMobile && <AppIcon size='tiny' icon={icon} background={icon_background} />}
{isMobile && <AppIcon size='tiny' iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />}
{!isMobile && (
<>
<div className='flex items-center space-x-2 w-0 grow'>
<AppIcon size='tiny' icon={icon} background={icon_background} />
<AppIcon size='tiny' iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
<div className='overflow-hidden text-ellipsis whitespace-nowrap' title={name}>{name}</div>
</div>
<div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>

View File

@@ -109,14 +109,16 @@ const SideBar: FC<IExploreSideBarProps> = ({
height: 'calc(100vh - 250px)',
}}
>
{installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon, icon_background } }) => {
{installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }) => {
return (
<Item
key={id}
isMobile={isMobile}
name={name}
icon_type={icon_type}
icon={icon}
icon_background={icon_background}
icon_url={icon_url}
id={id}
isSelected={lastSegment?.toLowerCase() === id}
isPinned={is_pinned}

View File

@@ -411,7 +411,13 @@ const TextGeneration: FC<IMainProps> = ({
}
}, [siteInfo?.title, canReplaceLogo])
useAppFavicon(!isInstalledApp, siteInfo?.icon, siteInfo?.icon_background)
useAppFavicon({
enable: !isInstalledApp,
icon_type: siteInfo?.icon_type,
icon: siteInfo?.icon,
icon_background: siteInfo?.icon_background,
icon_url: siteInfo?.icon_url,
})
const [isShowResSidebar, { setTrue: doShowResSidebar, setFalse: hideResSidebar }] = useBoolean(false)
const showResSidebar = () => {