You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useCallback, useEffect, useState } from 'react'
  4. import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
  5. import Link from 'next/link'
  6. import { Trans, useTranslation } from 'react-i18next'
  7. import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
  8. import Modal from '@/app/components/base/modal'
  9. import ActionButton from '@/app/components/base/action-button'
  10. import Button from '@/app/components/base/button'
  11. import Divider from '@/app/components/base/divider'
  12. import Input from '@/app/components/base/input'
  13. import Textarea from '@/app/components/base/textarea'
  14. import AppIcon from '@/app/components/base/app-icon'
  15. import Switch from '@/app/components/base/switch'
  16. import PremiumBadge from '@/app/components/base/premium-badge'
  17. import { SimpleSelect } from '@/app/components/base/select'
  18. import type { AppDetailResponse } from '@/models/app'
  19. import type { AppIconType, AppSSO, Language } from '@/types/app'
  20. import { useToastContext } from '@/app/components/base/toast'
  21. import { languages } from '@/i18n/language'
  22. import Tooltip from '@/app/components/base/tooltip'
  23. import { useProviderContext } from '@/context/provider-context'
  24. import { useModalContext } from '@/context/modal-context'
  25. import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
  26. import AppIconPicker from '@/app/components/base/app-icon-picker'
  27. import cn from '@/utils/classnames'
  28. import { useDocLink } from '@/context/i18n'
  29. export type ISettingsModalProps = {
  30. isChat: boolean
  31. appInfo: AppDetailResponse & Partial<AppSSO>
  32. isShow: boolean
  33. defaultValue?: string
  34. onClose: () => void
  35. onSave?: (params: ConfigParams) => Promise<void>
  36. }
  37. export type ConfigParams = {
  38. title: string
  39. description: string
  40. default_language: string
  41. chat_color_theme: string
  42. chat_color_theme_inverted: boolean
  43. prompt_public: boolean
  44. copyright: string
  45. privacy_policy: string
  46. custom_disclaimer: string
  47. icon_type: AppIconType
  48. icon: string
  49. icon_background?: string
  50. show_workflow_steps: boolean
  51. use_icon_as_answer_icon: boolean
  52. enable_sso?: boolean
  53. }
  54. const prefixSettings = 'appOverview.overview.appInfo.settings'
  55. const SettingsModal: FC<ISettingsModalProps> = ({
  56. isChat,
  57. appInfo,
  58. isShow = false,
  59. onClose,
  60. onSave,
  61. }) => {
  62. const { notify } = useToastContext()
  63. const [isShowMore, setIsShowMore] = useState(false)
  64. const {
  65. title,
  66. icon_type,
  67. icon,
  68. icon_background,
  69. icon_url,
  70. description,
  71. chat_color_theme,
  72. chat_color_theme_inverted,
  73. copyright,
  74. privacy_policy,
  75. custom_disclaimer,
  76. default_language,
  77. show_workflow_steps,
  78. use_icon_as_answer_icon,
  79. } = appInfo.site
  80. const [inputInfo, setInputInfo] = useState({
  81. title,
  82. desc: description,
  83. chatColorTheme: chat_color_theme,
  84. chatColorThemeInverted: chat_color_theme_inverted,
  85. copyright,
  86. copyrightSwitchValue: !!copyright,
  87. privacyPolicy: privacy_policy,
  88. customDisclaimer: custom_disclaimer,
  89. show_workflow_steps,
  90. use_icon_as_answer_icon,
  91. enable_sso: appInfo.enable_sso,
  92. })
  93. const [language, setLanguage] = useState(default_language)
  94. const [saveLoading, setSaveLoading] = useState(false)
  95. const { t } = useTranslation()
  96. const docLink = useDocLink()
  97. const [showAppIconPicker, setShowAppIconPicker] = useState(false)
  98. const [appIcon, setAppIcon] = useState<AppIconSelection>(
  99. icon_type === 'image'
  100. ? { type: 'image', url: icon_url!, fileId: icon }
  101. : { type: 'emoji', icon, background: icon_background! },
  102. )
  103. const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
  104. const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
  105. const isFreePlan = plan.type === 'sandbox'
  106. const handlePlanClick = useCallback(() => {
  107. if (isFreePlan)
  108. setShowPricingModal()
  109. else
  110. setShowAccountSettingModal({ payload: 'billing' })
  111. }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
  112. useEffect(() => {
  113. setInputInfo({
  114. title,
  115. desc: description,
  116. chatColorTheme: chat_color_theme,
  117. chatColorThemeInverted: chat_color_theme_inverted,
  118. copyright,
  119. copyrightSwitchValue: !!copyright,
  120. privacyPolicy: privacy_policy,
  121. customDisclaimer: custom_disclaimer,
  122. show_workflow_steps,
  123. use_icon_as_answer_icon,
  124. enable_sso: appInfo.enable_sso,
  125. })
  126. setLanguage(default_language)
  127. setAppIcon(icon_type === 'image'
  128. ? { type: 'image', url: icon_url!, fileId: icon }
  129. : { type: 'emoji', icon, background: icon_background! })
  130. }, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon])
  131. const onHide = () => {
  132. onClose()
  133. setTimeout(() => {
  134. setIsShowMore(false)
  135. }, 200)
  136. }
  137. const onClickSave = async () => {
  138. if (!inputInfo.title) {
  139. notify({ type: 'error', message: t('app.newApp.nameNotEmpty') })
  140. return
  141. }
  142. const validateColorHex = (hex: string | null) => {
  143. if (hex === null || hex?.length === 0)
  144. return true
  145. const regex = /#([A-Fa-f0-9]{6})/
  146. const check = regex.test(hex)
  147. return check
  148. }
  149. const validatePrivacyPolicy = (privacyPolicy: string | null) => {
  150. if (privacyPolicy === null || privacyPolicy?.length === 0)
  151. return true
  152. return privacyPolicy.startsWith('http://') || privacyPolicy.startsWith('https://')
  153. }
  154. if (inputInfo !== null) {
  155. if (!validateColorHex(inputInfo.chatColorTheme)) {
  156. notify({ type: 'error', message: t(`${prefixSettings}.invalidHexMessage`) })
  157. return
  158. }
  159. if (!validatePrivacyPolicy(inputInfo.privacyPolicy)) {
  160. notify({ type: 'error', message: t(`${prefixSettings}.invalidPrivacyPolicy`) })
  161. return
  162. }
  163. }
  164. setSaveLoading(true)
  165. const params = {
  166. title: inputInfo.title,
  167. description: inputInfo.desc,
  168. default_language: language,
  169. chat_color_theme: inputInfo.chatColorTheme,
  170. chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
  171. prompt_public: false,
  172. copyright: !webappCopyrightEnabled
  173. ? ''
  174. : inputInfo.copyrightSwitchValue
  175. ? inputInfo.copyright
  176. : '',
  177. privacy_policy: inputInfo.privacyPolicy,
  178. custom_disclaimer: inputInfo.customDisclaimer,
  179. icon_type: appIcon.type,
  180. icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
  181. icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
  182. show_workflow_steps: inputInfo.show_workflow_steps,
  183. use_icon_as_answer_icon: inputInfo.use_icon_as_answer_icon,
  184. enable_sso: inputInfo.enable_sso,
  185. }
  186. await onSave?.(params)
  187. setSaveLoading(false)
  188. onHide()
  189. }
  190. const onChange = (field: string) => {
  191. return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  192. let value: string | boolean
  193. if (e.target.type === 'checkbox')
  194. value = (e.target as HTMLInputElement).checked
  195. else
  196. value = e.target.value
  197. setInputInfo(item => ({ ...item, [field]: value }))
  198. }
  199. }
  200. const onDesChange = (value: string) => {
  201. setInputInfo(item => ({ ...item, desc: value }))
  202. }
  203. return (
  204. <>
  205. <Modal
  206. isShow={isShow}
  207. closable={false}
  208. onClose={onHide}
  209. className='max-w-[520px] p-0'
  210. >
  211. {/* header */}
  212. <div className='pb-3 pl-6 pr-5 pt-5'>
  213. <div className='flex items-center gap-1'>
  214. <div className='title-2xl-semi-bold grow text-text-primary'>{t(`${prefixSettings}.title`)}</div>
  215. <ActionButton className='shrink-0' onClick={onHide}>
  216. <RiCloseLine className='h-4 w-4' />
  217. </ActionButton>
  218. </div>
  219. <div className='system-xs-regular mt-0.5 text-text-tertiary'>
  220. <span>{t(`${prefixSettings}.modalTip`)}</span>
  221. <Link href={docLink('/guides/application-publishing/launch-your-webapp-quickly/README')}
  222. target='_blank' rel='noopener noreferrer' className='text-text-accent'>{t('common.operation.learnMore')}</Link>
  223. </div>
  224. </div>
  225. {/* form body */}
  226. <div className='space-y-5 px-6 py-3'>
  227. {/* name & icon */}
  228. <div className='flex gap-4'>
  229. <div className='grow'>
  230. <div className={cn('system-sm-semibold mb-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.webName`)}</div>
  231. <Input
  232. className='w-full'
  233. value={inputInfo.title}
  234. onChange={onChange('title')}
  235. placeholder={t('app.appNamePlaceholder') || ''}
  236. />
  237. </div>
  238. <AppIcon
  239. size='xxl'
  240. onClick={() => { setShowAppIconPicker(true) }}
  241. className='mt-2 cursor-pointer'
  242. iconType={appIcon.type}
  243. icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
  244. background={appIcon.type === 'image' ? undefined : appIcon.background}
  245. imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
  246. />
  247. </div>
  248. {/* description */}
  249. <div className='relative'>
  250. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.webDesc`)}</div>
  251. <Textarea
  252. className='mt-1'
  253. value={inputInfo.desc}
  254. onChange={e => onDesChange(e.target.value)}
  255. placeholder={t(`${prefixSettings}.webDescPlaceholder`) as string}
  256. />
  257. <p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`)}</p>
  258. </div>
  259. <Divider className="my-0 h-px" />
  260. {/* answer icon */}
  261. {isChat && (
  262. <div className='w-full'>
  263. <div className='flex items-center justify-between'>
  264. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t('app.answerIcon.title')}</div>
  265. <Switch
  266. defaultValue={inputInfo.use_icon_as_answer_icon}
  267. onChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
  268. />
  269. </div>
  270. <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t('app.answerIcon.description')}</p>
  271. </div>
  272. )}
  273. {/* language */}
  274. <div className='flex items-center'>
  275. <div className={cn('system-sm-semibold grow py-1 text-text-secondary')}>{t(`${prefixSettings}.language`)}</div>
  276. <SimpleSelect
  277. wrapperClassName='w-[200px]'
  278. items={languages.filter(item => item.supported)}
  279. defaultValue={language}
  280. onSelect={item => setLanguage(item.value as Language)}
  281. notClearable
  282. />
  283. </div>
  284. {/* theme color */}
  285. {isChat && (
  286. <div className='flex items-center'>
  287. <div className='grow'>
  288. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`)}</div>
  289. <div className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.chatColorThemeDesc`)}</div>
  290. </div>
  291. <div className='shrink-0'>
  292. <Input
  293. className='mb-1 w-[200px]'
  294. value={inputInfo.chatColorTheme ?? ''}
  295. onChange={onChange('chatColorTheme')}
  296. placeholder='E.g #A020F0'
  297. />
  298. <div className='flex items-center justify-between'>
  299. <p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`)}</p>
  300. <Switch defaultValue={inputInfo.chatColorThemeInverted} onChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
  301. </div>
  302. </div>
  303. </div>
  304. )}
  305. {/* workflow detail */}
  306. <div className='w-full'>
  307. <div className='flex items-center justify-between'>
  308. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.workflow.subTitle`)}</div>
  309. <Switch
  310. disabled={!(appInfo.mode === 'workflow' || appInfo.mode === 'advanced-chat')}
  311. defaultValue={inputInfo.show_workflow_steps}
  312. onChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
  313. />
  314. </div>
  315. <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p>
  316. </div>
  317. {/* more settings switch */}
  318. <Divider className="my-0 h-px" />
  319. {!isShowMore && (
  320. <div className='flex cursor-pointer items-center' onClick={() => setIsShowMore(true)}>
  321. <div className='grow'>
  322. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.entry`)}</div>
  323. <p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.more.copyRightPlaceholder`)} & {t(`${prefixSettings}.more.privacyPolicyPlaceholder`)}</p>
  324. </div>
  325. <RiArrowRightSLine className='ml-1 h-4 w-4 shrink-0 text-text-secondary' />
  326. </div>
  327. )}
  328. {/* more settings */}
  329. {isShowMore && (
  330. <>
  331. {/* copyright */}
  332. <div className='w-full'>
  333. <div className='flex items-center'>
  334. <div className='flex grow items-center'>
  335. <div className={cn('system-sm-semibold mr-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.more.copyright`)}</div>
  336. {/* upgrade button */}
  337. {enableBilling && isFreePlan && (
  338. <div className='h-[18px] select-none'>
  339. <PremiumBadge size='s' color='blue' allowHover={true} onClick={handlePlanClick}>
  340. <SparklesSoft className='flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0' />
  341. <div className='system-xs-medium'>
  342. <span className='p-1'>
  343. {t('billing.upgradeBtn.encourageShort')}
  344. </span>
  345. </div>
  346. </PremiumBadge>
  347. </div>
  348. )}
  349. </div>
  350. <Tooltip
  351. disabled={webappCopyrightEnabled}
  352. popupContent={
  353. <div className='w-[180px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div>
  354. }
  355. asChild={false}
  356. >
  357. <Switch
  358. disabled={!webappCopyrightEnabled}
  359. defaultValue={inputInfo.copyrightSwitchValue}
  360. onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
  361. />
  362. </Tooltip>
  363. </div>
  364. <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.more.copyrightTip`)}</p>
  365. {inputInfo.copyrightSwitchValue && (
  366. <Input
  367. className='mt-2 h-10'
  368. value={inputInfo.copyright}
  369. onChange={onChange('copyright')}
  370. placeholder={t(`${prefixSettings}.more.copyRightPlaceholder`) as string}
  371. />
  372. )}
  373. </div>
  374. {/* privacy policy */}
  375. <div className='w-full'>
  376. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`)}</div>
  377. <p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>
  378. <Trans
  379. i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
  380. components={{ privacyPolicyLink: <Link href={'https://dify.ai/privacy'} target='_blank' rel='noopener noreferrer' className='text-text-accent' /> }}
  381. />
  382. </p>
  383. <Input
  384. className='mt-1'
  385. value={inputInfo.privacyPolicy}
  386. onChange={onChange('privacyPolicy')}
  387. placeholder={t(`${prefixSettings}.more.privacyPolicyPlaceholder`) as string}
  388. />
  389. </div>
  390. {/* custom disclaimer */}
  391. <div className='w-full'>
  392. <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`)}</div>
  393. <p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`)}</p>
  394. <Textarea
  395. className='mt-1'
  396. value={inputInfo.customDisclaimer}
  397. onChange={onChange('customDisclaimer')}
  398. placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`) as string}
  399. />
  400. </div>
  401. </>
  402. )}
  403. </div>
  404. {/* footer */}
  405. <div className='flex justify-end p-6 pt-5'>
  406. <Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
  407. <Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
  408. </div>
  409. {showAppIconPicker && (
  410. <div onClick={e => e.stopPropagation()}>
  411. <AppIconPicker
  412. onSelect={(payload) => {
  413. setAppIcon(payload)
  414. setShowAppIconPicker(false)
  415. }}
  416. onClose={() => {
  417. setAppIcon(icon_type === 'image'
  418. ? { type: 'image', url: icon_url!, fileId: icon }
  419. : { type: 'emoji', icon, background: icon_background! })
  420. setShowAppIconPicker(false)
  421. }}
  422. />
  423. </div>
  424. )}
  425. </Modal>
  426. </>
  427. )
  428. }
  429. export default React.memo(SettingsModal)