This is an automated email from the ASF dual-hosted git repository. shuai pushed a commit to branch ui/admin in repository https://gitbox.apache.org/repos/asf/answer.git
commit 0a5c44941bb6ca54333f15d6cab8697dfdaa9b2c Author: shuai <[email protected]> AuthorDate: Thu Jan 22 12:17:24 2026 +0800 feat: Management background navigation machine classification adjustment, regardless of functional split or reorganization --- i18n/en_US.yaml | 9 +- i18n/zh_CN.yaml | 9 +- ui/src/common/constants.ts | 58 ++- ui/src/common/interface.ts | 37 +- ui/src/components/AccordionNav/index.tsx | 65 ++- ui/src/components/AdminSideNav/index.tsx | 13 +- ui/src/components/SchemaForm/components/Switch.tsx | 3 +- ui/src/components/SchemaForm/index.tsx | 8 +- ui/src/components/SchemaForm/types.ts | 2 +- ui/src/components/TabNav/index.tsx | 26 ++ ui/src/components/index.ts | 2 + ui/src/pages/Admin/Answers/index.tsx | 8 +- ui/src/pages/Admin/Branding/index.tsx | 16 +- ui/src/pages/Admin/CssAndHtml/index.tsx | 16 +- .../Dashboard/components/HealthStatus/index.tsx | 6 +- ui/src/pages/Admin/Files/index.tsx | 261 ++++++++++++ ui/src/pages/Admin/General/index.tsx | 29 +- ui/src/pages/Admin/Interface/index.tsx | 75 +--- ui/src/pages/Admin/Login/index.tsx | 30 +- ui/src/pages/Admin/Plugins/Config/index.tsx | 18 +- ui/src/pages/Admin/{Legal => Policies}/index.tsx | 63 +-- ui/src/pages/Admin/Privileges/index.tsx | 32 +- ui/src/pages/Admin/QaSettings/index.tsx | 132 ++++++ ui/src/pages/Admin/Questions/index.tsx | 4 +- ui/src/pages/Admin/Security/index.tsx | 145 +++++++ ui/src/pages/Admin/Seo/index.tsx | 16 +- ui/src/pages/Admin/Smtp/index.tsx | 16 +- ui/src/pages/Admin/TagsSettings/index.tsx | 170 ++++++++ ui/src/pages/Admin/Themes/index.tsx | 16 +- ui/src/pages/Admin/Users/index.tsx | 3 + ui/src/pages/Admin/UsersSettings/index.tsx | 132 ++++++ ui/src/pages/Admin/Write/index.tsx | 473 --------------------- ui/src/pages/Admin/index.scss | 4 + ui/src/pages/Admin/index.tsx | 16 +- ui/src/pages/Layout/index.tsx | 5 +- ui/src/pages/Users/Settings/Profile/index.tsx | 10 +- ui/src/router/routes.ts | 36 +- ui/src/services/admin/index.ts | 1 + ui/src/services/admin/question.ts | 10 + ui/src/services/admin/settings.ts | 38 +- .../Admin/index.scss => services/admin/tags.ts} | 23 +- ui/src/services/admin/users.ts | 19 + ui/src/stores/index.ts | 4 +- ui/src/stores/interface.ts | 4 +- ui/src/stores/loginSetting.ts | 1 - ui/src/stores/siteInfo.ts | 3 +- ui/src/stores/{siteLegal.ts => siteSecurity.ts} | 16 +- ui/src/stores/writeSetting.ts | 12 +- ui/src/utils/guard.ts | 15 +- 49 files changed, 1304 insertions(+), 806 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 653793f2..ea607366 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1816,6 +1816,13 @@ ui: plugins: Plugins installed_plugins: Installed Plugins apperance: Appearance + community: Community + advanced: Advanced + tags: Tags + rules: Rules + policies: Policies + security: Security + files: Files website_welcome: Welcome to {{site_name}} user_center: login: Login @@ -2128,7 +2135,7 @@ ui: always_display: Always display external content ask_before_display: Ask before displaying external content write: - page_title: Write + page_title: Files min_content: label: Minimum question body length text: Minimum allowed question body length in characters. diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index a6c7f198..29e18151 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -1778,6 +1778,13 @@ ui: plugins: 插件 installed_plugins: 已安装插件 apperance: 外观 + community: 社区 + advanced: 高级 + tags: 标签 + rules: 规则 + policies: 政策 + security: 安全 + files: 文件 website_welcome: 欢迎来到 {{site_name}} user_center: login: 登录 @@ -2089,7 +2096,7 @@ ui: always_display: 总是显示外部内容 ask_before_display: 在显示外部内容之前询问 write: - page_title: 编辑 + page_title: 文件 min_content: label: 最小问题长度 text: 最小允许的问题内容长度(字符)。 diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 18f25114..285da474 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -83,6 +83,11 @@ export const ADMIN_LIST_STATUS = { }, }; +/** + * ADMIN_NAV_MENUS is the navigation menu for the admin panel. + * pathPrefix is used to activate the menu item when the activeKey starts with the pathPrefix. + */ + export const ADMIN_NAV_MENUS = [ { name: 'dashboard', @@ -92,15 +97,19 @@ export const ADMIN_NAV_MENUS = [ { name: 'contents', icon: 'file-earmark-text-fill', - children: [{ name: 'questions' }, { name: 'answers' }], + children: [ + { name: 'questions', path: 'qa/questions', pathPrefix: 'qa/' }, + { name: 'tags', path: 'tags/settings', pathPrefix: 'tags/' }, + ], }, { - name: 'users', + name: 'community', icon: 'people-fill', - }, - { - name: 'badges', - icon: 'award-fill', + children: [ + { name: 'users', pathPrefix: 'users/' }, + { name: 'badges' }, + { name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' }, + ], }, { name: 'apperance', @@ -113,20 +122,19 @@ export const ADMIN_NAV_MENUS = [ name: 'customize', }, { name: 'branding' }, + { name: 'interface' }, ], }, { - name: 'settings', + name: 'advanced', icon: 'gear-fill', children: [ { name: 'general' }, - { name: 'interface' }, - { name: 'smtp' }, - { name: 'legal' }, - { name: 'write' }, - { name: 'seo' }, + { name: 'security' }, + { name: 'files' }, { name: 'login' }, - { name: 'privileges' }, + { name: 'seo' }, + { name: 'smtp' }, ], }, { @@ -141,6 +149,30 @@ export const ADMIN_NAV_MENUS = [ }, ]; +export const ADMIN_QA_NAV_MENUS = [ + { name: 'questions', path: '/admin/qa/questions' }, + { name: 'answers', path: '/admin/qa/answers' }, + { name: 'settings', path: '/admin/qa/settings' }, +]; + +export const ADMIN_TAGS_NAV_MENUS = [ + // { name: 'tags', path: '/admin/tags' }, + { + name: 'settings', + path: '/admin/tags/settings', + }, +]; + +export const ADMIN_USERS_NAV_MENUS = [ + { name: 'users', path: '/admin/users' }, + { name: 'settings', path: '/admin/users/settings' }, +]; + +export const ADMIN_RULES_NAV_MENUS = [ + { name: 'privileges', path: '/admin/rules/privileges' }, + { name: 'policies', path: '/admin/rules/policies' }, +]; + export const TIMEZONES = [ { label: 'Africa', diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 3a77047e..aac89cce 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -364,7 +364,6 @@ export interface AdminSettingsGeneral { description: string; site_url: string; contact_email: string; - check_update: boolean; permalink?: number; } @@ -382,8 +381,6 @@ export interface HelmetUpdate extends Omit<HelmetBase, 'pageTitle'> { export interface AdminSettingsInterface { language: string; time_zone?: string; - default_avatar: string; - gravatar_base_url: string; } export interface AdminSettingsSmtp { @@ -405,6 +402,14 @@ export interface AdminSettingsUsers { allow_update_location: boolean; allow_update_username: boolean; allow_update_website: boolean; + default_avatar: string; + gravatar_base_url: string; +} + +export interface AdminSettingsSecurity { + external_content_display: string; + check_update: boolean; + login_required: boolean; } export interface SiteSettings { @@ -416,10 +421,12 @@ export interface SiteSettings { theme: AdminSettingsTheme; site_seo: AdminSettingsSeo; site_users: AdminSettingsUsers; - site_write: AdminSettingsWrite; + site_advanced: AdminSettingsWrite; + site_questions: AdminQuestionSetting; + site_tags: AdminTagsSetting; version: string; revision: string; - site_legal: AdminSettingsLegal; + site_security: AdminSettingsSecurity; } export interface AdminSettingBranding { @@ -430,7 +437,6 @@ export interface AdminSettingBranding { } export interface AdminSettingsLegal { - external_content_display: string; privacy_policy_original_text?: string; privacy_policy_parsed_text?: string; terms_of_service_original_text?: string; @@ -438,12 +444,6 @@ export interface AdminSettingsLegal { } export interface AdminSettingsWrite { - restrict_answer?: boolean; - min_tags?: number; - min_content?: number; - recommend_tags?: Tag[]; - required_tag?: boolean; - reserved_tags?: Tag[]; max_image_size?: number; max_attachment_size?: number; max_image_megapixel?: number; @@ -484,7 +484,6 @@ export interface AdminSettingsCustom { export interface AdminSettingsLogin { allow_new_registrations: boolean; - login_required: boolean; allow_email_registrations: boolean; allow_email_domains: string[]; allow_password_login: boolean; @@ -809,3 +808,15 @@ export interface BadgeDetailListRes { count: number; list: BadgeDetailListItem[]; } + +export interface AdminQuestionSetting { + min_tags: number; + min_content: number; + restrict_answer: boolean; +} + +export interface AdminTagsSetting { + recommend_tags: Tag[]; + required_tag: boolean; + reserved_tags: Tag[]; +} diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index ee081978..583c0198 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -28,16 +28,32 @@ import { floppyNavigation } from '@/utils'; import { Icon } from '@/components'; import './index.css'; +export interface MenuItem { + name: string; + path?: string; + pathPrefix?: string; + icon?: string; + displayName?: string; + badgeContent?: string | number; + children?: MenuItem[]; +} + function MenuNode({ menu, callback, activeKey, expanding = false, path = '/', +}: { + menu: MenuItem; + callback: (evt: any, menu: MenuItem, href: string, isLeaf: boolean) => void; + activeKey: string; + expanding?: boolean; + path?: string; }) { const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); - const isLeaf = !menu.children.length; - const href = isLeaf ? `${path}${menu.path}` : '#'; + const isLeaf = !menu.children || menu.children.length === 0; + const href = isLeaf ? `${path}${menu.path || ''}` : '#'; return ( <Nav.Item key={menu.path} className="w-100"> @@ -51,7 +67,14 @@ function MenuNode({ }} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, active: activeKey === menu.path }, + { + expanding, + active: + activeKey === menu.path || + (menu.path && activeKey.startsWith(`${menu.path}/`)) || + // if pathPrefix is set, activate when activeKey starts with the pathPrefix + (menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)), + }, )}> {menu?.icon && <Icon name={menu.icon} className="me-2" />} @@ -75,7 +98,13 @@ function MenuNode({ }} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, active: activeKey === menu.path }, + { + expanding, + active: + activeKey === menu.path || + (menu.path && activeKey.startsWith(`${menu.path}/`)) || + (menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)), + }, )}> {menu?.icon && <Icon name={menu.icon} className="me-2" />} <span className="me-auto text-truncate"> @@ -90,8 +119,8 @@ function MenuNode({ </Nav.Link> )} - {menu.children.length ? ( - <Accordion.Collapse eventKey={menu.path} className="ms-4"> + {menu.children && menu.children.length > 0 ? ( + <Accordion.Collapse eventKey={menu.path || menu.name} className="ms-4"> <> {menu.children.map((leaf) => { return ( @@ -100,7 +129,7 @@ function MenuNode({ callback={callback} activeKey={activeKey} path={path} - key={leaf.path} + key={leaf.path || leaf.name} /> ); })} @@ -112,7 +141,7 @@ function MenuNode({ } interface AccordionProps { - menus: any[]; + menus: MenuItem[]; path?: string; } const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => { @@ -137,19 +166,27 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => { }); const splat = pathMatch && pathMatch.params['*']; - let activeKey = menus[0].path; + let activeKey: string = menus[0]?.path || menus[0]?.name || ''; + if (splat) { activeKey = splat; } + const getOpenKey = () => { let openKey = ''; menus.forEach((li) => { - if (li.children.length) { + if (li.children && li.children.length > 0) { const matchedChild = li.children.find((el) => { - return el.path === activeKey; + // exact match or path prefix match + return ( + el.path === activeKey || + (el.path && activeKey.startsWith(`${el.path}/`)) || + // if pathPrefix is set, activate when activeKey starts with the pathPrefix + (el.pathPrefix && activeKey.startsWith(el.pathPrefix)) + ); }); if (matchedChild) { - openKey = li.path; + openKey = li.path || li.name || ''; } } }); @@ -181,8 +218,8 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => { path={path} callback={menuClick} activeKey={activeKey} - expanding={openKey === li.path} - key={li.path} + expanding={openKey === (li.path || li.name)} + key={li.path || li.name} /> ); })} diff --git a/ui/src/components/AdminSideNav/index.tsx b/ui/src/components/AdminSideNav/index.tsx index a2d36fbd..b6c4d4bc 100644 --- a/ui/src/components/AdminSideNav/index.tsx +++ b/ui/src/components/AdminSideNav/index.tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'; import cloneDeep from 'lodash/cloneDeep'; import { AccordionNav, Icon } from '@/components'; +import type { MenuItem } from '@/components/AccordionNav'; import { ADMIN_NAV_MENUS } from '@/common/constants'; import { useQueryPlugins } from '@/services'; import { interfaceStore } from '@/stores'; @@ -37,16 +38,18 @@ const AdminSideNav = () => { have_config: true, }); - const menus = cloneDeep(ADMIN_NAV_MENUS); + const menus = cloneDeep(ADMIN_NAV_MENUS) as MenuItem[]; if (configurablePlugins && configurablePlugins.length > 0) { menus.forEach((item) => { if (item.name === 'plugins' && item.children) { item.children = [ ...item.children, - ...configurablePlugins.map((plugin) => ({ - name: plugin.slug_name, - displayName: plugin.name, - })), + ...configurablePlugins.map( + (plugin): MenuItem => ({ + name: plugin.slug_name, + displayName: plugin.name, + }), + ), ]; } }); diff --git a/ui/src/components/SchemaForm/components/Switch.tsx b/ui/src/components/SchemaForm/components/Switch.tsx index 336142ae..81f928d3 100644 --- a/ui/src/components/SchemaForm/components/Switch.tsx +++ b/ui/src/components/SchemaForm/components/Switch.tsx @@ -51,6 +51,7 @@ const Index: FC<Props> = ({ onChange(state); } }; + return ( <Form.Check name={fieldName} @@ -59,7 +60,7 @@ const Index: FC<Props> = ({ checked={fieldObject?.value || ''} feedback={fieldObject?.errorMsg} feedbackType="invalid" - isInvalid={fieldObject.isInvalid} + isInvalid={fieldObject?.isInvalid} disabled={readOnly} onChange={handleChange} /> diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx index 171b5f67..01f50681 100644 --- a/ui/src/components/SchemaForm/index.tsx +++ b/ui/src/components/SchemaForm/index.tsx @@ -290,7 +290,7 @@ const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = ( controlId={key} className={classnames( groupClassName, - formData[key].hidden ? 'd-none' : null, + formData[key]?.hidden ? 'd-none' : null, )}> {/* Uniform processing `label` */} {title && !uiSimplify ? <Form.Label>{title}</Form.Label> : null} @@ -437,12 +437,12 @@ const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = ( /> ) : null} {/* Unified handling of `Feedback` and `Text` */} - <Form.Control.Feedback type="invalid"> - {fieldState?.errorMsg} - </Form.Control.Feedback> {description && widget !== 'tag_selector' ? ( <Form.Text dangerouslySetInnerHTML={{ __html: description }} /> ) : null} + <Form.Control.Feedback type="invalid"> + {fieldState?.errorMsg} + </Form.Control.Feedback> </Form.Group> ); })} diff --git a/ui/src/components/SchemaForm/types.ts b/ui/src/components/SchemaForm/types.ts index 55cf8e99..25e5c56c 100644 --- a/ui/src/components/SchemaForm/types.ts +++ b/ui/src/components/SchemaForm/types.ts @@ -44,7 +44,7 @@ export interface JSONSchema { required?: string[]; properties: { [key: string]: { - type?: 'string' | 'boolean' | 'number'; + type?: 'string' | 'boolean' | 'number' | Type.Tag[]; title: string; description?: string; enum?: Array<string | boolean | number>; diff --git a/ui/src/components/TabNav/index.tsx b/ui/src/components/TabNav/index.tsx new file mode 100644 index 00000000..482cd0cf --- /dev/null +++ b/ui/src/components/TabNav/index.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; +import { Nav } from 'react-bootstrap'; +import { NavLink, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const TabNav: FC<{ menus: { name: string; path: string }[] }> = ({ menus }) => { + const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); + const { pathname } = useLocation(); + return ( + <Nav variant="underline" className="mb-4 border-bottom"> + {menus.map((menu) => ( + <Nav.Item key={menu.path}> + <NavLink + to={menu.path} + className={() => + pathname === menu.path ? 'nav-link active' : 'nav-link' + }> + {t(menu.name)} + </NavLink> + </Nav.Item> + ))} + </Nav> + ); +}; + +export default TabNav; diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 68e863d2..416db241 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -64,6 +64,7 @@ import CardBadge from './CardBadge'; import PinList from './PinList'; import MobileSideNav from './MobileSideNav'; import AdminSideNav from './AdminSideNav'; +import TabNav from './TabNav'; export { Avatar, @@ -115,5 +116,6 @@ export { PinList, MobileSideNav, AdminSideNav, + TabNav, }; export type { EditorRef, JSONSchema, UISchema }; diff --git a/ui/src/pages/Admin/Answers/index.tsx b/ui/src/pages/Admin/Answers/index.tsx index 07190024..9909739f 100644 --- a/ui/src/pages/Admin/Answers/index.tsx +++ b/ui/src/pages/Admin/Answers/index.tsx @@ -32,8 +32,9 @@ import { Empty, QueryGroup, Modal, + TabNav, } from '@/components'; -import { ADMIN_LIST_STATUS } from '@/common/constants'; +import { ADMIN_LIST_STATUS, ADMIN_QA_NAV_MENUS } from '@/common/constants'; import * as Type from '@/common/interface'; import { deletePermanently, useAnswerSearch } from '@/services'; import { escapeRemove } from '@/utils'; @@ -96,7 +97,10 @@ const Answers: FC = () => { }; return ( <> - <h3 className="mb-4">{t('page_title')}</h3> + <h3 className="mb-4"> + {t('page_title', { keyPrefix: 'admin.questions' })} + </h3> + <TabNav menus={ADMIN_QA_NAV_MENUS} /> <div className="d-flex flex-wrap justify-content-between align-items-center"> <Stack direction="horizontal" gap={3} className="mb-3"> <QueryGroup diff --git a/ui/src/pages/Admin/Branding/index.tsx b/ui/src/pages/Admin/Branding/index.tsx index bec7fc0f..ce2417cb 100644 --- a/ui/src/pages/Admin/Branding/index.tsx +++ b/ui/src/pages/Admin/Branding/index.tsx @@ -169,13 +169,15 @@ const Index: FC = () => { return ( <ImgViewer> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - uiSchema={uiSchema} - formData={formData} - onSubmit={onSubmit} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onSubmit={onSubmit} + onChange={handleOnChange} + /> + </div> </ImgViewer> ); }; diff --git a/ui/src/pages/Admin/CssAndHtml/index.tsx b/ui/src/pages/Admin/CssAndHtml/index.tsx index 422659af..557ffa3c 100644 --- a/ui/src/pages/Admin/CssAndHtml/index.tsx +++ b/ui/src/pages/Admin/CssAndHtml/index.tsx @@ -151,13 +151,15 @@ const Index: FC = () => { return ( <> <h3 className="mb-4">{t('customize', { keyPrefix: 'nav_menus' })}</h3> - <SchemaForm - schema={schema} - formData={formData} - onSubmit={onSubmit} - uiSchema={uiSchema} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + formData={formData} + onSubmit={onSubmit} + uiSchema={uiSchema} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx index 137fcd2e..9a79edd9 100644 --- a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx +++ b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import type * as Type from '@/common/interface'; -import { siteInfoStore } from '@/stores'; +import { siteSecurityStore } from '@/stores'; const { gt, gte } = require('semver'); @@ -34,7 +34,7 @@ interface IProps { const HealthStatus: FC<IProps> = ({ data }) => { const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' }); const { version, remote_version } = data.version_info || {}; - const { siteInfo } = siteInfoStore(); + const { check_update } = siteSecurityStore.getState(); let isLatest = false; let hasNewerVersion = false; const downloadUrl = `https://answer.apache.org/download?from_version=${version}`; @@ -68,7 +68,7 @@ const HealthStatus: FC<IProps> = ({ data }) => { {t('update_to')} {remote_version} </a> )} - {!isLatest && !remote_version && siteInfo.check_update && ( + {!isLatest && !remote_version && check_update && ( <a className="ms-1 badge rounded-pill text-bg-danger" target="_blank" diff --git a/ui/src/pages/Admin/Files/index.tsx b/ui/src/pages/Admin/Files/index.tsx new file mode 100644 index 00000000..8c8650e8 --- /dev/null +++ b/ui/src/pages/Admin/Files/index.tsx @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Form, Button } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; +import { useToast } from '@/hooks'; +import { getAdminFilesSetting, updateAdminFilesSetting } from '@/services'; +import { handleFormError, scrollToElementTop } from '@/utils'; +import { writeSettingStore } from '@/stores'; + +const initFormData = { + max_image_size: { + value: 0, + errorMsg: '', + isInvalid: false, + }, + max_attachment_size: { + value: 0, + errorMsg: '', + isInvalid: false, + }, + max_image_megapixel: { + value: 0, + errorMsg: '', + isInvalid: false, + }, + authorized_image_extensions: { + value: '', + errorMsg: '', + isInvalid: false, + }, + authorized_attachment_extensions: { + value: '', + errorMsg: '', + isInvalid: false, + }, +}; + +const Index: FC = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.write', + }); + const Toast = useToast(); + + const [formData, setFormData] = useState(initFormData); + + const handleValueChange = (value) => { + setFormData({ + ...formData, + ...value, + }); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + + const reqParams: Type.AdminSettingsWrite = { + max_image_size: Number(formData.max_image_size.value), + max_attachment_size: Number(formData.max_attachment_size.value), + max_image_megapixel: Number(formData.max_image_megapixel.value), + authorized_image_extensions: + formData.authorized_image_extensions.value?.length > 0 + ? formData.authorized_image_extensions.value + .split(',') + ?.map((item) => item.trim().toLowerCase()) + : [], + authorized_attachment_extensions: + formData.authorized_attachment_extensions.value?.length > 0 + ? formData.authorized_attachment_extensions.value + .split(',') + ?.map((item) => item.trim().toLowerCase()) + : [], + }; + updateAdminFilesSetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + writeSettingStore.getState().update({ ...reqParams }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + const initData = () => { + getAdminFilesSetting().then((res) => { + formData.max_image_size.value = res.max_image_size; + formData.max_attachment_size.value = res.max_attachment_size; + formData.max_image_megapixel.value = res.max_image_megapixel; + formData.authorized_image_extensions.value = + res.authorized_image_extensions?.join(', ').toLowerCase(); + formData.authorized_attachment_extensions.value = + res.authorized_attachment_extensions?.join(', ').toLowerCase(); + setFormData({ ...formData }); + }); + }; + + useEffect(() => { + initData(); + }, []); + + return ( + <> + <h3 className="mb-4">{t('page_title')}</h3> + <div className="max-w-748"> + <Form noValidate onSubmit={onSubmit}> + <Form.Group className="mb-3" controlId="max_image_size"> + <Form.Label>{t('image_size.label')}</Form.Label> + <Form.Control + type="number" + inputMode="numeric" + min={0} + value={formData.max_image_size.value} + isInvalid={formData.max_image_size.isInvalid} + onChange={(evt) => { + handleValueChange({ + max_image_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('image_size.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.max_image_size.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="max_attachment_size"> + <Form.Label>{t('attachment_size.label')}</Form.Label> + <Form.Control + type="number" + inputMode="numeric" + min={0} + value={formData.max_attachment_size.value} + isInvalid={formData.max_attachment_size.isInvalid} + onChange={(evt) => { + handleValueChange({ + max_attachment_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('attachment_size.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.max_attachment_size.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="max_image_megapixel"> + <Form.Label>{t('image_megapixels.label')}</Form.Label> + <Form.Control + type="number" + inputMode="numeric" + min={0} + isInvalid={formData.max_image_megapixel.isInvalid} + value={formData.max_image_megapixel.value} + onChange={(evt) => { + handleValueChange({ + max_image_megapixel: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('image_megapixels.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.max_image_megapixel.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="authorized_image_extensions"> + <Form.Label>{t('image_extensions.label')}</Form.Label> + <Form.Control + type="text" + value={formData.authorized_image_extensions.value} + isInvalid={formData.authorized_image_extensions.isInvalid} + onChange={(evt) => { + handleValueChange({ + authorized_image_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('image_extensions.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.authorized_image_extensions.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group + className="mb-3" + controlId="authorized_attachment_extensions"> + <Form.Label>{t('attachment_extensions.label')}</Form.Label> + <Form.Control + type="text" + value={formData.authorized_attachment_extensions.value} + isInvalid={formData.authorized_attachment_extensions.isInvalid} + onChange={(evt) => { + handleValueChange({ + authorized_attachment_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('attachment_extensions.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.authorized_attachment_extensions.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3"> + <Button type="submit">{t('save', { keyPrefix: 'btns' })}</Button> + </Form.Group> + </Form> + </div> + </> + ); +}; + +export default Index; diff --git a/ui/src/pages/Admin/General/index.tsx b/ui/src/pages/Admin/General/index.tsx index ce2cdeb5..57602f45 100644 --- a/ui/src/pages/Admin/General/index.tsx +++ b/ui/src/pages/Admin/General/index.tsx @@ -70,11 +70,6 @@ const General: FC = () => { title: t('contact_email.label'), description: t('contact_email.text'), }, - check_update: { - type: 'boolean', - title: t('check_update.label'), - default: true, - }, }, }; const uiSchema: UISchema = { @@ -114,12 +109,6 @@ const General: FC = () => { }, }, }, - check_update: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('check_update.text'), - }, - }, }; const [formData, setFormData] = useState<Type.FormDataType>( initFormData(schema), @@ -134,7 +123,6 @@ const General: FC = () => { short_description: formData.short_description.value, site_url: formData.site_url.value, contact_email: formData.contact_email.value, - check_update: formData.check_update.value, }; updateGeneralSetting(reqParams) @@ -149,7 +137,6 @@ const General: FC = () => { formData.short_description.value = res.short_description; formData.site_url.value = res.site_url; formData.contact_email.value = res.contact_email; - formData.check_update.value = res.check_update; } setFormData({ ...formData }); @@ -183,13 +170,15 @@ const General: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - formData={formData} - onSubmit={onSubmit} - uiSchema={uiSchema} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + formData={formData} + onSubmit={onSubmit} + uiSchema={uiSchema} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Interface/index.tsx b/ui/src/pages/Admin/Interface/index.tsx index 865029e9..49e5175d 100644 --- a/ui/src/pages/Admin/Interface/index.tsx +++ b/ui/src/pages/Admin/Interface/index.tsx @@ -28,7 +28,7 @@ import { } from '@/common/interface'; import { interfaceStore, loggedUserInfoStore } from '@/stores'; import { JSONSchema, SchemaForm, UISchema } from '@/components'; -import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants'; +import { DEFAULT_TIMEZONE } from '@/common/constants'; import { updateInterfaceSetting, useInterfaceSetting, @@ -68,20 +68,6 @@ const Interface: FC = () => { description: t('time_zone.text'), default: setting?.time_zone || DEFAULT_TIMEZONE, }, - default_avatar: { - type: 'string', - title: t('avatar.label'), - description: t('avatar.text'), - enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value), - enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label), - default: setting?.default_avatar || 'system', - }, - gravatar_base_url: { - type: 'string', - title: t('gravatar_base_url.label'), - description: t('gravatar_base_url.text'), - default: setting?.gravatar_base_url || '', - }, }, }; @@ -96,16 +82,6 @@ const Interface: FC = () => { isInvalid: false, errorMsg: '', }, - default_avatar: { - value: setting?.default_avatar || 'system', - isInvalid: false, - errorMsg: '', - }, - gravatar_base_url: { - value: setting?.gravatar_base_url || '', - isInvalid: false, - errorMsg: '', - }, }); const uiSchema: UISchema = { @@ -115,15 +91,6 @@ const Interface: FC = () => { time_zone: { 'ui:widget': 'timezone', }, - default_avatar: { - 'ui:widget': 'select', - }, - gravatar_base_url: { - 'ui:widget': 'input', - 'ui:options': { - placeholder: 'https://www.gravatar.com/avatar/', - }, - }, }; const getLangs = async () => { const res: LangsType[] = await loadLanguageOptions(true); @@ -156,8 +123,6 @@ const Interface: FC = () => { const reqParams: AdminSettingsInterface = { language: formData.language.value, time_zone: formData.time_zone.value, - default_avatar: formData.default_avatar.value, - gravatar_base_url: formData.gravatar_base_url.value, }; updateInterfaceSetting(reqParams) @@ -185,20 +150,18 @@ const Interface: FC = () => { useEffect(() => { if (setting) { - const formMeta = {}; - Object.keys(setting).forEach((k) => { - let v = setting[k]; - if (k === 'default_avatar' && !v) { - v = 'system'; - } - if (k === 'gravatar_base_url' && !v) { - v = ''; - } - formMeta[k] = { ...formData[k], value: v }; - }); + const formMeta = { ...formData }; + if (setting.language) { + formMeta.language.value = setting.language; + } else { + formMeta.language.value = storeInterface.language || langs?.[0]?.value; + } + if (setting.time_zone) { + formMeta.time_zone.value = setting.time_zone; + } setFormData({ ...formData, ...formMeta }); } - }, [setting]); + }, [setting, langs]); useEffect(() => { getLangs(); }, []); @@ -209,13 +172,15 @@ const Interface: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - uiSchema={uiSchema} - formData={formData} - onSubmit={onSubmit} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onSubmit={onSubmit} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Login/index.tsx b/ui/src/pages/Admin/Login/index.tsx index c43cfcff..a4a61152 100644 --- a/ui/src/pages/Admin/Login/index.tsx +++ b/ui/src/pages/Admin/Login/index.tsx @@ -58,12 +58,6 @@ const Index: FC = () => { title: t('allowed_email_domains.title'), description: t('allowed_email_domains.text'), }, - login_required: { - type: 'boolean', - title: t('private.title'), - description: t('private.text'), - default: false, - }, }, }; const uiSchema: UISchema = { @@ -88,12 +82,6 @@ const Index: FC = () => { allow_email_domains: { 'ui:widget': 'textarea', }, - login_required: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('private.label'), - }, - }, }; const [formData, setFormData] = useState(initFormData(schema)); const { update: updateLoginSetting } = loginSettingStore((_) => _); @@ -116,7 +104,6 @@ const Index: FC = () => { allow_new_registrations: formData.allow_new_registrations.value, allow_email_registrations: formData.allow_email_registrations.value, allow_email_domains: allowedEmailDomains, - login_required: formData.login_required.value, allow_password_login: formData.allow_password_login.value, }; @@ -151,7 +138,6 @@ const Index: FC = () => { formMeta.allow_email_domains.value = setting.allow_email_domains.join('\n'); } - formMeta.login_required.value = setting.login_required; formMeta.allow_password_login.value = setting.allow_password_login; setFormData({ ...formMeta }); } @@ -165,13 +151,15 @@ const Index: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - formData={formData} - onSubmit={onSubmit} - uiSchema={uiSchema} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + formData={formData} + onSubmit={onSubmit} + uiSchema={uiSchema} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Plugins/Config/index.tsx b/ui/src/pages/Admin/Plugins/Config/index.tsx index 7c47d016..44d83ad6 100644 --- a/ui/src/pages/Admin/Plugins/Config/index.tsx +++ b/ui/src/pages/Admin/Plugins/Config/index.tsx @@ -114,14 +114,16 @@ const Config = () => { return ( <> <h3 className="mb-4">{data?.name}</h3> - <SchemaForm - schema={schema} - uiSchema={uiSchema} - refreshConfig={refreshConfig} - formData={formData} - onSubmit={onSubmit} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + refreshConfig={refreshConfig} + formData={formData} + onSubmit={onSubmit} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Legal/index.tsx b/ui/src/pages/Admin/Policies/index.tsx similarity index 70% rename from ui/src/pages/Admin/Legal/index.tsx rename to ui/src/pages/Admin/Policies/index.tsx index 4a4e4ea1..7170c39f 100644 --- a/ui/src/pages/Admin/Legal/index.tsx +++ b/ui/src/pages/Admin/Policies/index.tsx @@ -23,11 +23,17 @@ import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import type * as Type from '@/common/interface'; -import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components'; +import { + SchemaForm, + JSONSchema, + initFormData, + UISchema, + TabNav, +} from '@/components'; import { useToast } from '@/hooks'; -import { getLegalSetting, putLegalSetting } from '@/services'; +import { getPoliciesSetting, putPoliciesSetting } from '@/services'; import { handleFormError, scrollToElementTop } from '@/utils'; -import { siteLealStore } from '@/stores'; +import { ADMIN_RULES_NAV_MENUS } from '@/common/constants'; const Legal: FC = () => { const { t } = useTranslation('translation', { @@ -35,29 +41,10 @@ const Legal: FC = () => { }); const Toast = useToast(); - const externalContent = [ - { - value: 'always_display', - label: t('external_content_display.always_display'), - }, - { - value: 'ask_before_display', - label: t('external_content_display.ask_before_display'), - }, - ]; - const schema: JSONSchema = { title: t('page_title'), required: ['terms_of_service', 'privacy_policy'], properties: { - external_content_display: { - type: 'string', - title: t('external_content_display.label'), - description: t('external_content_display.text'), - enum: externalContent?.map((lang) => lang.value), - enumNames: externalContent?.map((lang) => lang.label), - default: 0, - }, terms_of_service: { type: 'string', title: t('terms_of_service.label'), @@ -71,9 +58,6 @@ const Legal: FC = () => { }, }; const uiSchema: UISchema = { - external_content_display: { - 'ui:widget': 'select', - }, terms_of_service: { 'ui:widget': 'textarea', 'ui:options': { @@ -94,7 +78,6 @@ const Legal: FC = () => { evt.stopPropagation(); const reqParams: Type.AdminSettingsLegal = { - external_content_display: formData.external_content_display.value, terms_of_service_original_text: formData.terms_of_service.value, terms_of_service_parsed_text: marked.parse( formData.terms_of_service.value, @@ -103,15 +86,12 @@ const Legal: FC = () => { privacy_policy_parsed_text: marked.parse(formData.privacy_policy.value), }; - putLegalSetting(reqParams) + putPoliciesSetting(reqParams) .then(() => { Toast.onShow({ msg: t('update', { keyPrefix: 'toast' }), variant: 'success', }); - siteLealStore.getState().update({ - external_content_display: reqParams.external_content_display, - }); }) .catch((err) => { if (err.isError) { @@ -124,11 +104,9 @@ const Legal: FC = () => { }; useEffect(() => { - getLegalSetting().then((setting) => { + getPoliciesSetting().then((setting) => { if (setting) { const formMeta = { ...formData }; - formMeta.external_content_display.value = - setting.external_content_display; formMeta.terms_of_service.value = setting.terms_of_service_original_text; formMeta.privacy_policy.value = setting.privacy_policy_original_text; @@ -143,14 +121,17 @@ const Legal: FC = () => { return ( <> - <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - formData={formData} - onSubmit={onSubmit} - uiSchema={uiSchema} - onChange={handleOnChange} - /> + <h3 className="mb-4">{t('rules', { keyPrefix: 'nav_menus' })}</h3> + <TabNav menus={ADMIN_RULES_NAV_MENUS} /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + formData={formData} + onSubmit={onSubmit} + uiSchema={uiSchema} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Privileges/index.tsx b/ui/src/pages/Admin/Privileges/index.tsx index f0930c7a..9ab775dc 100644 --- a/ui/src/pages/Admin/Privileges/index.tsx +++ b/ui/src/pages/Admin/Privileges/index.tsx @@ -22,7 +22,13 @@ import { useTranslation } from 'react-i18next'; import { useToast } from '@/hooks'; import { FormDataType } from '@/common/interface'; -import { JSONSchema, SchemaForm, UISchema, initFormData } from '@/components'; +import { + JSONSchema, + SchemaForm, + UISchema, + initFormData, + TabNav, +} from '@/components'; import { getPrivilegeSetting, putPrivilegeSetting, @@ -30,7 +36,10 @@ import { AdminSettingsPrivilegeReq, } from '@/services'; import { handleFormError, scrollToElementTop } from '@/utils'; -import { ADMIN_PRIVILEGE_CUSTOM_LEVEL } from '@/common/constants'; +import { + ADMIN_PRIVILEGE_CUSTOM_LEVEL, + ADMIN_RULES_NAV_MENUS, +} from '@/common/constants'; const Index: FC = () => { const { t } = useTranslation('translation', { @@ -187,14 +196,17 @@ const Index: FC = () => { return ( <> - <h3 className="mb-4">{t('title')}</h3> - <SchemaForm - schema={schema} - uiSchema={uiSchema} - formData={formData} - onSubmit={onSubmit} - onChange={handleOnChange} - /> + <h3 className="mb-4">{t('rules', { keyPrefix: 'nav_menus' })}</h3> + <TabNav menus={ADMIN_RULES_NAV_MENUS} /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onSubmit={onSubmit} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/QaSettings/index.tsx b/ui/src/pages/Admin/QaSettings/index.tsx new file mode 100644 index 00000000..0c2636ea --- /dev/null +++ b/ui/src/pages/Admin/QaSettings/index.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + SchemaForm, + JSONSchema, + UISchema, + initFormData, + TabNav, +} from '@/components'; +import { ADMIN_QA_NAV_MENUS } from '@/common/constants'; +import * as Type from '@/common/interface'; +import { writeSettingStore } from '@/stores'; +import { + getQuestionSetting, + updateQuestionSetting, +} from '@/services/admin/question'; +import { handleFormError, scrollToElementTop } from '@/utils'; +import { useToast } from '@/hooks'; + +const QaSettings = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.write', + }); + const Toast = useToast(); + const schema: JSONSchema = { + title: t('page_title'), + properties: { + min_tags: { + type: 'number', + title: t('min_tags.label'), + description: t('min_tags.text'), + }, + min_content: { + type: 'number', + title: t('min_content.label'), + description: t('min_content.text'), + }, + restrict_answer: { + type: 'boolean', + title: t('restrict_answer.label'), + description: t('restrict_answer.text'), + }, + }, + }; + const uiSchema: UISchema = { + min_tags: { + 'ui:widget': 'input', + 'ui:options': { + inputType: 'number', + }, + }, + min_content: { + 'ui:widget': 'input', + 'ui:options': { + inputType: 'number', + }, + }, + restrict_answer: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('restrict_answer.label'), + }, + }, + }; + const [formData, setFormData] = useState<Type.FormDataType>( + initFormData(schema), + ); + + const handleValueChange = (data: Type.FormDataType) => { + setFormData(data); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + // TODO: submit data + const reqParams: Type.AdminQuestionSetting = { + min_tags: formData.min_tags.value, + min_content: formData.min_content.value, + restrict_answer: formData.restrict_answer.value, + }; + updateQuestionSetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + writeSettingStore.getState().update({ ...reqParams }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + getQuestionSetting().then((res) => { + if (res) { + const formMeta = { ...formData }; + formMeta.min_tags.value = res.min_tags; + formMeta.min_content.value = res.min_content; + formMeta.restrict_answer.value = res.restrict_answer; + setFormData(formMeta); + } + }); + }, []); + + return ( + <> + <h3 className="mb-4"> + {t('page_title', { keyPrefix: 'admin.questions' })} + </h3> + <TabNav menus={ADMIN_QA_NAV_MENUS} /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onChange={handleValueChange} + onSubmit={onSubmit} + /> + </div> + </> + ); +}; + +export default QaSettings; diff --git a/ui/src/pages/Admin/Questions/index.tsx b/ui/src/pages/Admin/Questions/index.tsx index 494effe6..ffd9f609 100644 --- a/ui/src/pages/Admin/Questions/index.tsx +++ b/ui/src/pages/Admin/Questions/index.tsx @@ -32,8 +32,9 @@ import { Empty, QueryGroup, Modal, + TabNav, } from '@/components'; -import { ADMIN_LIST_STATUS } from '@/common/constants'; +import { ADMIN_LIST_STATUS, ADMIN_QA_NAV_MENUS } from '@/common/constants'; import * as Type from '@/common/interface'; import { deletePermanently, useQuestionSearch } from '@/services'; import { pathFactory } from '@/router/pathFactory'; @@ -95,6 +96,7 @@ const Questions: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> + <TabNav menus={ADMIN_QA_NAV_MENUS} /> <div className="d-flex flex-wrap justify-content-between align-items-center"> <Stack direction="horizontal" gap={3} className="mb-3"> <QueryGroup diff --git a/ui/src/pages/Admin/Security/index.tsx b/ui/src/pages/Admin/Security/index.tsx new file mode 100644 index 00000000..07eaeb5b --- /dev/null +++ b/ui/src/pages/Admin/Security/index.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type * as Type from '@/common/interface'; +import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components'; +import { siteSecurityStore } from '@/stores'; +import { + getSecuritySetting, + putSecuritySetting, +} from '@/services/admin/settings'; +import { handleFormError, scrollToElementTop } from '@/utils'; +import { useToast } from '@/hooks'; + +const Security = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.security', + }); + const Toast = useToast(); + const externalContent = [ + { + value: 'always_display', + label: t('external_content_display.always_display', { + keyPrefix: 'admin.legal', + }), + }, + { + value: 'ask_before_display', + label: t('external_content_display.ask_before_display', { + keyPrefix: 'admin.legal', + }), + }, + ]; + + const schema: JSONSchema = { + title: t('page_title'), + properties: { + login_required: { + type: 'boolean', + title: t('private.title', { keyPrefix: 'admin.login' }), + description: t('private.text', { keyPrefix: 'admin.login' }), + default: false, + }, + external_content_display: { + type: 'string', + title: t('external_content_display.label', { + keyPrefix: 'admin.legal', + }), + description: t('external_content_display.text', { + keyPrefix: 'admin.legal', + }), + enum: externalContent?.map((lang) => lang.value), + enumNames: externalContent?.map((lang) => lang.label), + default: 0, + }, + check_update: { + type: 'boolean', + title: t('check_update.label', { keyPrefix: 'admin.general' }), + default: true, + }, + }, + }; + const uiSchema: UISchema = { + login_required: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('private.label', { keyPrefix: 'admin.login' }), + }, + }, + external_content_display: { + 'ui:widget': 'select', + 'ui:options': { + label: t('external_content_display.label', { + keyPrefix: 'admin.legal', + }), + }, + }, + check_update: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('check_update.label', { keyPrefix: 'admin.general' }), + }, + }, + }; + const [formData, setFormData] = useState(initFormData(schema)); + + const handleValueChange = (data: Type.FormDataType) => { + setFormData(data); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const reqParams = { + login_required: formData.login_required.value, + external_content_display: formData.external_content_display.value, + check_update: formData.check_update.value, + }; + putSecuritySetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + siteSecurityStore.getState().update(reqParams); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + getSecuritySetting().then((setting) => { + if (setting) { + const formMeta = { ...formData }; + formMeta.login_required.value = setting.login_required; + formMeta.external_content_display.value = + setting.external_content_display; + formMeta.check_update.value = setting.check_update; + setFormData(formMeta); + } + }); + }, []); + + return ( + <> + <h3 className="mb-4">{t('security', { keyPrefix: 'nav_menus' })}</h3> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onChange={handleValueChange} + onSubmit={onSubmit} + /> + </div> + </> + ); +}; + +export default Security; diff --git a/ui/src/pages/Admin/Seo/index.tsx b/ui/src/pages/Admin/Seo/index.tsx index e539595b..0675479d 100644 --- a/ui/src/pages/Admin/Seo/index.tsx +++ b/ui/src/pages/Admin/Seo/index.tsx @@ -117,13 +117,15 @@ const Index: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - formData={formData} - onSubmit={onSubmit} - uiSchema={uiSchema} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + formData={formData} + onSubmit={onSubmit} + uiSchema={uiSchema} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Smtp/index.tsx b/ui/src/pages/Admin/Smtp/index.tsx index 85a1b976..59538751 100644 --- a/ui/src/pages/Admin/Smtp/index.tsx +++ b/ui/src/pages/Admin/Smtp/index.tsx @@ -224,13 +224,15 @@ const Smtp: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - uiSchema={uiSchema} - formData={formData} - onChange={handleOnChange} - onSubmit={onSubmit} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onChange={handleOnChange} + onSubmit={onSubmit} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/TagsSettings/index.tsx b/ui/src/pages/Admin/TagsSettings/index.tsx new file mode 100644 index 00000000..f2ab2f5b --- /dev/null +++ b/ui/src/pages/Admin/TagsSettings/index.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + SchemaForm, + JSONSchema, + UISchema, + initFormData, + TabNav, +} from '@/components'; +import { ADMIN_TAGS_NAV_MENUS } from '@/common/constants'; +import * as Type from '@/common/interface'; +import { handleFormError, scrollToElementTop } from '@/utils'; +import { writeSettingStore } from '@/stores'; +import { getAdminTagsSetting, updateAdminTagsSetting } from '@/services/admin'; +import { useToast } from '@/hooks'; + +const QaSettings = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.write', + }); + const Toast = useToast(); + const schema: JSONSchema = { + title: t('page_title'), + properties: { + reserved_tags: { + type: 'string', + title: t('reserved_tags.label'), + description: t('reserved_tags.text'), + }, + recommend_tags: { + type: 'string', + title: t('recommend_tags.label'), + description: t('recommend_tags.text'), + }, + required_tag: { + type: 'boolean', + title: t('required_tag.title'), + description: t('required_tag.text'), + }, + }, + }; + const uiSchema: UISchema = { + reserved_tags: { + 'ui:widget': 'tag_selector', + 'ui:options': { + label: t('reserved_tags.label'), + }, + }, + recommend_tags: { + 'ui:widget': 'tag_selector', + 'ui:options': { + label: t('recommend_tags.label'), + }, + }, + required_tag: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('required_tag.label'), + }, + }, + }; + const [formData, setFormData] = useState<Type.FormDataType>( + initFormData(schema), + ); + + const handleValueChange = (data: Type.FormDataType) => { + setFormData(data); + }; + + const checkValidated = (): boolean => { + let bol = true; + const { recommend_tags, reserved_tags } = formData; + // 找出 recommend_tags 和 reserved_tags 中是否有重复的标签 + // 通过标签中的 slug_name 来去重 + const repeatTag = recommend_tags.value.filter((tag) => + reserved_tags.value.some((rTag) => rTag?.slug_name === tag?.slug_name), + ); + if (repeatTag.length > 0) { + handleValueChange({ + ...formData, + recommend_tags: { + ...recommend_tags, + errorMsg: t('recommend_tags.msg.contain_reserved'), + isInvalid: true, + }, + }); + bol = false; + const ele = document.getElementById('recommend_tags'); + scrollToElementTop(ele); + } else { + handleValueChange({ + ...formData, + recommend_tags: { + ...recommend_tags, + errorMsg: '', + isInvalid: false, + }, + }); + } + return bol; + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + if (!checkValidated()) { + return; + } + const reqParams: Type.AdminTagsSetting = { + recommend_tags: formData.recommend_tags.value, + reserved_tags: formData.reserved_tags.value, + required_tag: formData.required_tag.value, + }; + updateAdminTagsSetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + writeSettingStore.getState().update({ ...reqParams }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + getAdminTagsSetting().then((res) => { + if (res) { + const formMeta = { ...formData }; + if (Array.isArray(res.recommend_tags)) { + formData.recommend_tags.value = res.recommend_tags; + } else { + formData.recommend_tags.value = []; + } + if (Array.isArray(res.reserved_tags)) { + formData.reserved_tags.value = res.reserved_tags; + } else { + formData.reserved_tags.value = []; + } + formMeta.required_tag.value = res.required_tag; + setFormData(formMeta); + } + }); + }, []); + + return ( + <> + <h3 className="mb-4">{t('tags', { keyPrefix: 'nav_menus' })}</h3> + <TabNav menus={ADMIN_TAGS_NAV_MENUS} /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onChange={handleValueChange} + onSubmit={onSubmit} + /> + </div> + </> + ); +}; + +export default QaSettings; diff --git a/ui/src/pages/Admin/Themes/index.tsx b/ui/src/pages/Admin/Themes/index.tsx index 873dd7c9..c6983cbe 100644 --- a/ui/src/pages/Admin/Themes/index.tsx +++ b/ui/src/pages/Admin/Themes/index.tsx @@ -205,13 +205,15 @@ const Index: FC = () => { return ( <> <h3 className="mb-4">{t('page_title')}</h3> - <SchemaForm - schema={schema} - formData={formData} - onSubmit={onSubmit} - uiSchema={uiSchema} - onChange={handleOnChange} - /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + formData={formData} + onSubmit={onSubmit} + uiSchema={uiSchema} + onChange={handleOnChange} + /> + </div> </> ); }; diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index 22042900..200aacf3 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -32,6 +32,7 @@ import { Empty, QueryGroup, Modal, + TabNav, } from '@/components'; import * as Type from '@/common/interface'; import { useUserModal } from '@/hooks'; @@ -45,6 +46,7 @@ import { deletePermanently, } from '@/services'; import { formatCount } from '@/utils'; +import { ADMIN_USERS_NAV_MENUS } from '@/common/constants'; import DeleteUserModal from './components/DeleteUserModal'; import Action from './components/Action'; @@ -208,6 +210,7 @@ const Users: FC = () => { return ( <> <h3 className="mb-4">{t('title')}</h3> + <TabNav menus={ADMIN_USERS_NAV_MENUS} /> <div className="d-flex flex-wrap justify-content-between align-items-center"> <Stack direction="horizontal" gap={3} className="mb-3"> <QueryGroup diff --git a/ui/src/pages/Admin/UsersSettings/index.tsx b/ui/src/pages/Admin/UsersSettings/index.tsx new file mode 100644 index 00000000..769c9f0a --- /dev/null +++ b/ui/src/pages/Admin/UsersSettings/index.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { SchemaForm, JSONSchema, UISchema, TabNav } from '@/components'; +import { + ADMIN_USERS_NAV_MENUS, + SYSTEM_AVATAR_OPTIONS, +} from '@/common/constants'; +import { FormDataType } from '@/common/interface'; +import { useAdminUsersSettings, updateAdminUsersSettings } from '@/services'; +import { useToast } from '@/hooks'; +import { siteInfoStore } from '@/stores'; +import { handleFormError, scrollToElementTop } from '@/utils'; + +const UsersSettings = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.interface', + }); + const { data: setting } = useAdminUsersSettings(); + const Toast = useToast(); + const schema: JSONSchema = { + title: t('page_title'), + properties: { + default_avatar: { + type: 'string', + title: t('avatar.label'), + description: t('avatar.text'), + enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value), + enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label), + default: setting?.default_avatar || 'system', + }, + gravatar_base_url: { + type: 'string', + title: t('gravatar_base_url.label'), + description: t('gravatar_base_url.text'), + default: setting?.gravatar_base_url || '', + }, + }, + }; + + const [formData, setFormData] = useState<FormDataType>({ + default_avatar: { + value: setting?.default_avatar || 'system', + isInvalid: false, + errorMsg: '', + }, + gravatar_base_url: { + value: setting?.gravatar_base_url || '', + isInvalid: false, + errorMsg: '', + }, + }); + + const uiSchema: UISchema = { + default_avatar: { + 'ui:widget': 'select', + }, + gravatar_base_url: { + 'ui:widget': 'input', + 'ui:options': { + placeholder: 'https://www.gravatar.com/avatar/', + }, + }, + }; + + const handleValueChange = (data: FormDataType) => { + setFormData(data); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const reqParams = { + default_avatar: formData.default_avatar.value, + gravatar_base_url: formData.gravatar_base_url.value, + }; + updateAdminUsersSettings(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + siteInfoStore.getState().updateUsers({ + ...siteInfoStore.getState().users, + ...reqParams, + }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + if (setting) { + const formMeta = {}; + Object.keys(setting).forEach((k) => { + let v = setting[k]; + if (k === 'default_avatar' && !v) { + v = 'system'; + } + if (k === 'gravatar_base_url' && !v) { + v = ''; + } + formMeta[k] = { ...formData[k], value: v }; + }); + setFormData({ ...formData, ...formMeta }); + } + }, [setting]); + + return ( + <> + <h3 className="mb-4">{t('tags', { keyPrefix: 'nav_menus' })}</h3> + <TabNav menus={ADMIN_USERS_NAV_MENUS} /> + <div className="max-w-748"> + <SchemaForm + schema={schema} + uiSchema={uiSchema} + formData={formData} + onChange={handleValueChange} + onSubmit={onSubmit} + /> + </div> + </> + ); +}; + +export default UsersSettings; diff --git a/ui/src/pages/Admin/Write/index.tsx b/ui/src/pages/Admin/Write/index.tsx deleted file mode 100644 index fee2d28c..00000000 --- a/ui/src/pages/Admin/Write/index.tsx +++ /dev/null @@ -1,473 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { FC, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Form, Button } from 'react-bootstrap'; - -import { TagSelector } from '@/components'; -import type * as Type from '@/common/interface'; -import { useToast } from '@/hooks'; -import { - getRequireAndReservedTag, - postRequireAndReservedTag, -} from '@/services'; -import { handleFormError, scrollToElementTop } from '@/utils'; -import { writeSettingStore } from '@/stores'; - -const initFormData = { - reserved_tags: { - value: [] as Type.Tag[], // Replace `Type.Tag` with the correct type for `reserved_tags.value` - errorMsg: '', - isInvalid: false, - }, - min_content: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - min_tags: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - recommend_tags: { - value: [] as Type.Tag[], - errorMsg: '', - isInvalid: false, - }, - required_tag: { - value: false, - errorMsg: '', - isInvalid: false, - }, - restrict_answer: { - value: false, - errorMsg: '', - isInvalid: false, - }, - max_image_size: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - max_attachment_size: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - max_image_megapixel: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - authorized_image_extensions: { - value: '', - errorMsg: '', - isInvalid: false, - }, - authorized_attachment_extensions: { - value: '', - errorMsg: '', - isInvalid: false, - }, -}; - -const Index: FC = () => { - const { t } = useTranslation('translation', { - keyPrefix: 'admin.write', - }); - const Toast = useToast(); - - const [formData, setFormData] = useState(initFormData); - - const handleValueChange = (value) => { - setFormData({ - ...formData, - ...value, - }); - }; - - const checkValidated = (): boolean => { - let bol = true; - const { recommend_tags, reserved_tags } = formData; - // 找出 recommend_tags 和 reserved_tags 中是否有重复的标签 - // 通过标签中的 slug_name 来去重 - const repeatTag = recommend_tags.value.filter((tag) => - reserved_tags.value.some((rTag) => rTag?.slug_name === tag?.slug_name), - ); - if (repeatTag.length > 0) { - handleValueChange({ - recommend_tags: { - ...recommend_tags, - errorMsg: t('recommend_tags.msg.contain_reserved'), - isInvalid: true, - }, - }); - bol = false; - const ele = document.getElementById('recommend_tags'); - scrollToElementTop(ele); - } else { - handleValueChange({ - recommend_tags: { - ...recommend_tags, - errorMsg: '', - isInvalid: false, - }, - }); - } - return bol; - }; - - const onSubmit = (evt) => { - evt.preventDefault(); - evt.stopPropagation(); - if (!checkValidated()) { - return; - } - const reqParams: Type.AdminSettingsWrite = { - recommend_tags: formData.recommend_tags.value, - min_tags: Number(formData.min_tags.value), - reserved_tags: formData.reserved_tags.value, - required_tag: formData.required_tag.value, - restrict_answer: formData.restrict_answer.value, - min_content: Number(formData.min_content.value), - max_image_size: Number(formData.max_image_size.value), - max_attachment_size: Number(formData.max_attachment_size.value), - max_image_megapixel: Number(formData.max_image_megapixel.value), - authorized_image_extensions: - formData.authorized_image_extensions.value?.length > 0 - ? formData.authorized_image_extensions.value - .split(',') - ?.map((item) => item.trim().toLowerCase()) - : [], - authorized_attachment_extensions: - formData.authorized_attachment_extensions.value?.length > 0 - ? formData.authorized_attachment_extensions.value - .split(',') - ?.map((item) => item.trim().toLowerCase()) - : [], - }; - postRequireAndReservedTag(reqParams) - .then(() => { - Toast.onShow({ - msg: t('update', { keyPrefix: 'toast' }), - variant: 'success', - }); - writeSettingStore - .getState() - .update({ restrict_answer: reqParams.restrict_answer, ...reqParams }); - }) - .catch((err) => { - if (err.isError) { - const data = handleFormError(err, formData); - setFormData({ ...data }); - const ele = document.getElementById(err.list[0].error_field); - scrollToElementTop(ele); - } - }); - }; - - const initData = () => { - getRequireAndReservedTag().then((res) => { - if (Array.isArray(res.recommend_tags)) { - formData.recommend_tags.value = res.recommend_tags; - } - formData.min_content.value = res.min_content; - formData.min_tags.value = res.min_tags; - formData.required_tag.value = res.required_tag; - formData.restrict_answer.value = res.restrict_answer; - if (Array.isArray(res.reserved_tags)) { - formData.reserved_tags.value = res.reserved_tags; - } - formData.max_image_size.value = res.max_image_size; - formData.max_attachment_size.value = res.max_attachment_size; - formData.max_image_megapixel.value = res.max_image_megapixel; - formData.authorized_image_extensions.value = - res.authorized_image_extensions?.join(', ').toLowerCase(); - formData.authorized_attachment_extensions.value = - res.authorized_attachment_extensions?.join(', ').toLowerCase(); - setFormData({ ...formData }); - }); - }; - - useEffect(() => { - initData(); - }, []); - - return ( - <> - <h3 className="mb-4">{t('page_title')}</h3> - <Form noValidate onSubmit={onSubmit}> - <Form.Group className="mb-3" controlId="reserved_tags"> - <Form.Label>{t('reserved_tags.label')}</Form.Label> - <TagSelector - value={formData.reserved_tags.value} - onChange={(val) => { - handleValueChange({ - reserved_tags: { - value: val, - errorMsg: '', - isInvalid: false, - }, - }); - }} - showRequiredTag={false} - maxTagLength={0} - tagStyleMode="simple" - formText={t('reserved_tags.text')} - isInvalid={formData.reserved_tags.isInvalid} - errMsg={formData.reserved_tags.errorMsg} - /> - </Form.Group> - - <Form.Group className="mb-3" controlId="recommend_tags"> - <Form.Label>{t('recommend_tags.label')}</Form.Label> - <TagSelector - value={formData.recommend_tags.value} - onChange={(val) => { - handleValueChange({ - recommend_tags: { - value: val, - errorMsg: '', - isInvalid: false, - }, - }); - }} - showRequiredTag={false} - tagStyleMode="simple" - formText={t('recommend_tags.text')} - isInvalid={formData.recommend_tags.isInvalid} - errMsg={formData.recommend_tags.errorMsg} - /> - </Form.Group> - <Form.Group className="mb-3" controlId="min_tags"> - <Form.Label>{t('min_tags.label')}</Form.Label> - <Form.Control - type="number" - inputMode="numeric" - min={0} - value={formData.min_tags.value} - isInvalid={formData.min_tags.isInvalid} - onChange={(evt) => { - handleValueChange({ - min_tags: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('min_tags.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.min_tags.errorMsg} - </Form.Control.Feedback> - </Form.Group> - <Form.Group className="mb-3" controlId="required_tag"> - <Form.Label>{t('required_tag.title')}</Form.Label> - <Form.Switch - label={t('required_tag.label')} - checked={formData.required_tag.value} - onChange={(evt) => { - handleValueChange({ - required_tag: { - value: evt.target.checked, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('required_tag.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.required_tag.errorMsg} - </Form.Control.Feedback> - </Form.Group> - <Form.Group className="mb-3" controlId="min_content"> - <Form.Label>{t('min_content.label')}</Form.Label> - <Form.Control - type="number" - inputMode="numeric" - min={0} - value={formData.min_content.value} - isInvalid={formData.min_content.isInvalid} - onChange={(evt) => { - handleValueChange({ - min_content: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('min_content.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.min_content.errorMsg} - </Form.Control.Feedback> - </Form.Group> - <Form.Group className="mb-3" controlId="restrict_answer"> - <Form.Label>{t('restrict_answer.title')}</Form.Label> - <Form.Switch - label={t('restrict_answer.label')} - checked={formData.restrict_answer.value} - onChange={(evt) => { - handleValueChange({ - restrict_answer: { - value: evt.target.checked, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('restrict_answer.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.restrict_answer.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="max_image_size"> - <Form.Label>{t('image_size.label')}</Form.Label> - <Form.Control - type="number" - inputMode="numeric" - min={0} - value={formData.max_image_size.value} - isInvalid={formData.max_image_size.isInvalid} - onChange={(evt) => { - handleValueChange({ - max_image_size: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('image_size.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.max_image_size.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="max_attachment_size"> - <Form.Label>{t('attachment_size.label')}</Form.Label> - <Form.Control - type="number" - inputMode="numeric" - min={0} - value={formData.max_attachment_size.value} - isInvalid={formData.max_attachment_size.isInvalid} - onChange={(evt) => { - handleValueChange({ - max_attachment_size: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('attachment_size.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.max_attachment_size.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="max_image_megapixel"> - <Form.Label>{t('image_megapixels.label')}</Form.Label> - <Form.Control - type="number" - inputMode="numeric" - min={0} - isInvalid={formData.max_image_megapixel.isInvalid} - value={formData.max_image_megapixel.value} - onChange={(evt) => { - handleValueChange({ - max_image_megapixel: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('image_megapixels.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.max_image_megapixel.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="authorized_image_extensions"> - <Form.Label>{t('image_extensions.label')}</Form.Label> - <Form.Control - type="text" - value={formData.authorized_image_extensions.value} - isInvalid={formData.authorized_image_extensions.isInvalid} - onChange={(evt) => { - handleValueChange({ - authorized_image_extensions: { - value: evt.target.value.toLowerCase(), - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('image_extensions.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.authorized_image_extensions.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group - className="mb-3" - controlId="authorized_attachment_extensions"> - <Form.Label>{t('attachment_extensions.label')}</Form.Label> - <Form.Control - type="text" - value={formData.authorized_attachment_extensions.value} - isInvalid={formData.authorized_attachment_extensions.isInvalid} - onChange={(evt) => { - handleValueChange({ - authorized_attachment_extensions: { - value: evt.target.value.toLowerCase(), - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - <Form.Text>{t('attachment_extensions.text')}</Form.Text> - <Form.Control.Feedback type="invalid"> - {formData.authorized_attachment_extensions.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3"> - <Button type="submit">{t('save', { keyPrefix: 'btns' })}</Button> - </Form.Group> - </Form> - </> - ); -}; - -export default Index; diff --git a/ui/src/pages/Admin/index.scss b/ui/src/pages/Admin/index.scss index 5748d053..c33e92be 100644 --- a/ui/src/pages/Admin/index.scss +++ b/ui/src/pages/Admin/index.scss @@ -25,6 +25,10 @@ max-width: 30rem; } +.max-w-748 { + max-width: 748px; +} + @media screen and (max-width: 768px) { .max-w-30 { max-width: 15rem; diff --git a/ui/src/pages/Admin/index.tsx b/ui/src/pages/Admin/index.tsx index 27503296..da167ac4 100644 --- a/ui/src/pages/Admin/index.tsx +++ b/ui/src/pages/Admin/index.tsx @@ -20,7 +20,7 @@ import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { Row, Col } from 'react-bootstrap'; -import { Outlet, useMatch } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import { usePageTags } from '@/hooks'; import { AdminSideNav, Footer } from '@/components'; @@ -28,19 +28,8 @@ import { AdminSideNav, Footer } from '@/components'; import '@/common/sideNavLayout.scss'; import './index.scss'; -const g10Paths = [ - 'dashboard', - 'questions', - 'answers', - 'users', - 'badges', - 'flags', - 'installed-plugins', -]; const Index: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); - const pathMatch = useMatch('/admin/:path'); - const curPath = pathMatch?.params.path || 'dashboard'; usePageTags({ title: t('admin'), @@ -59,9 +48,6 @@ const Index: FC = () => { <Col className="page-main flex-auto"> <Outlet /> </Col> - {g10Paths.find((v) => curPath === v) ? null : ( - <Col className="page-right-side" /> - )} </Row> </div> </div> diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index bd229165..048ca812 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -28,7 +28,7 @@ import { toastStore, loginToContinueStore, errorCodeStore, - siteLealStore, + siteSecurityStore, themeSettingStore, } from '@/stores'; import { @@ -49,7 +49,7 @@ const Layout: FC = () => { const location = useLocation(); const { msg: toastMsg, variant, clear: toastClear } = toastStore(); const externalToast = useExternalToast(); - const externalContentDisplay = siteLealStore( + const externalContentDisplay = siteSecurityStore( (state) => state.external_content_display, ); const closeToast = () => { @@ -59,7 +59,6 @@ const Layout: FC = () => { const { show: showLoginToContinueModal } = loginToContinueStore(); const { data: notificationData } = useQueryNotificationStatus(); const layout = themeSettingStore((state) => state.layout); - console.log(layout); useEffect(() => { // handle footnote links const fixFootnoteLinks = () => { diff --git a/ui/src/pages/Users/Settings/Profile/index.tsx b/ui/src/pages/Users/Settings/Profile/index.tsx index 7fb8247c..62bd6fb8 100644 --- a/ui/src/pages/Users/Settings/Profile/index.tsx +++ b/ui/src/pages/Users/Settings/Profile/index.tsx @@ -25,7 +25,7 @@ import { sha256 } from 'js-sha256'; import type { FormDataType } from '@/common/interface'; import { UploadImg, Avatar, Icon, ImgViewer } from '@/components'; -import { loggedUserInfoStore, userCenterStore, interfaceStore } from '@/stores'; +import { loggedUserInfoStore, userCenterStore, siteInfoStore } from '@/stores'; import { useToast } from '@/hooks'; import { modifyUserInfo, @@ -42,7 +42,7 @@ const Index: React.FC = () => { const toast = useToast(); const { user, update } = loggedUserInfoStore(); const { agent: ucAgent } = userCenterStore(); - const { interface: interfaceSetting } = interfaceStore(); + const { users: usersSettings } = siteInfoStore(); const [mailHash, setMailHash] = useState(''); const [count] = useState(0); const [profileAgent, setProfileAgent] = useState<UcSettingAgent>(); @@ -384,7 +384,7 @@ const Index: React.FC = () => { <span>{t('avatar.gravatar_text')}</span> <a href={ - interfaceSetting.gravatar_base_url.includes( + usersSettings.gravatar_base_url.includes( 'gravatar.cn', ) ? 'https://gravatar.cn' @@ -393,9 +393,7 @@ const Index: React.FC = () => { className="ms-1" target="_blank" rel="noreferrer"> - {interfaceSetting.gravatar_base_url.includes( - 'gravatar.cn', - ) + {usersSettings.gravatar_base_url.includes('gravatar.cn') ? 'gravatar.cn' : 'gravatar.com'} </a> diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 3ca8431f..7d7003a1 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -358,9 +358,25 @@ const routes: RouteNode[] = [ page: 'pages/Admin/Dashboard', }, { - path: 'answers', + path: 'qa/questions', + page: 'pages/Admin/Questions', + }, + { + path: 'qa/answers', page: 'pages/Admin/Answers', }, + { + path: 'qa/settings', + page: 'pages/Admin/QaSettings', + }, + { + path: 'tags/settings', + page: 'pages/Admin/TagsSettings', + }, + { + path: 'security', + page: 'pages/Admin/Security', + }, { path: 'themes', page: 'pages/Admin/Themes', @@ -377,14 +393,14 @@ const routes: RouteNode[] = [ path: 'interface', page: 'pages/Admin/Interface', }, - { - path: 'questions', - page: 'pages/Admin/Questions', - }, { path: 'users', page: 'pages/Admin/Users', }, + { + path: 'users/settings', + page: 'pages/Admin/UsersSettings', + }, { path: 'users/:user_id', page: 'pages/Admin/UserOverview', @@ -398,12 +414,12 @@ const routes: RouteNode[] = [ page: 'pages/Admin/Branding', }, { - path: 'legal', - page: 'pages/Admin/Legal', + path: 'rules/policies', + page: 'pages/Admin/Policies', }, { - path: 'write', - page: 'pages/Admin/Write', + path: 'files', + page: 'pages/Admin/Files', }, { path: 'seo', @@ -414,7 +430,7 @@ const routes: RouteNode[] = [ page: 'pages/Admin/Login', }, { - path: 'privileges', + path: 'rules/privileges', page: 'pages/Admin/Privileges', }, { diff --git a/ui/src/services/admin/index.ts b/ui/src/services/admin/index.ts index af83d365..3fa211ee 100644 --- a/ui/src/services/admin/index.ts +++ b/ui/src/services/admin/index.ts @@ -25,3 +25,4 @@ export * from './users'; export * from './dashboard'; export * from './plugins'; export * from './badges'; +export * from './tags'; diff --git a/ui/src/services/admin/question.ts b/ui/src/services/admin/question.ts index 670534e2..bcd3ac7f 100644 --- a/ui/src/services/admin/question.ts +++ b/ui/src/services/admin/question.ts @@ -46,3 +46,13 @@ export const changeQuestionStatus = ( status, }); }; + +export const getQuestionSetting = () => { + return request.get<Type.AdminQuestionSetting>( + '/answer/admin/api/siteinfo/question', + ); +}; + +export const updateQuestionSetting = (params: Type.AdminQuestionSetting) => { + return request.put('/answer/admin/api/siteinfo/question', params); +}; diff --git a/ui/src/services/admin/settings.ts b/ui/src/services/admin/settings.ts index f2b9e598..c1f99d40 100644 --- a/ui/src/services/admin/settings.ts +++ b/ui/src/services/admin/settings.ts @@ -122,22 +122,12 @@ export const brandSetting = (params: Type.AdminSettingBranding) => { return request.put('/answer/admin/api/siteinfo/branding', params); }; -export const getRequireAndReservedTag = () => { - return request.get('/answer/admin/api/siteinfo/write'); +export const getAdminFilesSetting = () => { + return request.get('/answer/admin/api/siteinfo/advanced'); }; -export const postRequireAndReservedTag = (params) => { - return request.put('/answer/admin/api/siteinfo/write', params); -}; - -export const getLegalSetting = () => { - return request.get<Type.AdminSettingsLegal>( - '/answer/admin/api/siteinfo/legal', - ); -}; - -export const putLegalSetting = (params: Type.AdminSettingsLegal) => { - return request.put('/answer/admin/api/siteinfo/legal', params); +export const updateAdminFilesSetting = (params: Type.AdminSettingsWrite) => { + return request.put('/answer/admin/api/siteinfo/advanced', params); }; export const getSeoSetting = () => { @@ -195,3 +185,23 @@ export const getPrivilegeSetting = () => { export const putPrivilegeSetting = (params: AdminSettingsPrivilegeReq) => { return request.put('/answer/admin/api/setting/privileges', params); }; + +export const getPoliciesSetting = () => { + return request.get<Type.AdminSettingsLegal>( + '/answer/admin/api/siteinfo/polices', + ); +}; + +export const putPoliciesSetting = (params: Type.AdminSettingsLegal) => { + return request.put('/answer/admin/api/siteinfo/polices', params); +}; + +export const getSecuritySetting = () => { + return request.get<Type.AdminSettingsSecurity>( + '/answer/admin/api/siteinfo/security', + ); +}; + +export const putSecuritySetting = (params: Type.AdminSettingsSecurity) => { + return request.put('/answer/admin/api/siteinfo/security', params); +}; diff --git a/ui/src/pages/Admin/index.scss b/ui/src/services/admin/tags.ts similarity index 69% copy from ui/src/pages/Admin/index.scss copy to ui/src/services/admin/tags.ts index 5748d053..c7d6bc77 100644 --- a/ui/src/pages/Admin/index.scss +++ b/ui/src/services/admin/tags.ts @@ -17,20 +17,13 @@ * under the License. */ -.min-w-15 { - min-width: 15rem; -} +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; -.max-w-30 { - max-width: 30rem; -} +export const getAdminTagsSetting = () => { + return request.get<Type.AdminTagsSetting>('/answer/admin/api/siteinfo/tag'); +}; -@media screen and (max-width: 768px) { - .max-w-30 { - max-width: 15rem; - } -} - -.table tr th { - white-space: nowrap; -} +export const updateAdminTagsSetting = (params: Type.AdminTagsSetting) => { + return request.put('/answer/admin/api/siteinfo/tag', params); +}; diff --git a/ui/src/services/admin/users.ts b/ui/src/services/admin/users.ts index ee8cad5d..c7aeafe5 100644 --- a/ui/src/services/admin/users.ts +++ b/ui/src/services/admin/users.ts @@ -94,3 +94,22 @@ export const postUserActivation = (userId: string) => { user_id: userId, }); }; + +export const useAdminUsersSettings = () => { + const apiUrl = `/answer/admin/api/siteinfo/users-settings`; + const { data, error } = useSWR< + { + default_avatar: string; + gravatar_base_url: string; + }, + Error + >(apiUrl, request.instance.get); + return { data, isLoading: !data && !error, error }; +}; + +export const updateAdminUsersSettings = (params: { + default_avatar: string; + gravatar_base_url: string; +}) => { + return request.put('/answer/admin/api/siteinfo/users-settings', params); +}; diff --git a/ui/src/stores/index.ts b/ui/src/stores/index.ts index 66d59b32..1bd2fece 100644 --- a/ui/src/stores/index.ts +++ b/ui/src/stores/index.ts @@ -33,7 +33,7 @@ import loginToContinueStore from './loginToContinue'; import errorCodeStore from './errorCode'; import sideNavStore from './sideNav'; import commentReplyStore from './commentReply'; -import siteLealStore from './siteLegal'; +import siteSecurityStore from './siteSecurity'; export { toastStore, @@ -52,5 +52,5 @@ export { sideNavStore, commentReplyStore, writeSettingStore, - siteLealStore, + siteSecurityStore, }; diff --git a/ui/src/stores/interface.ts b/ui/src/stores/interface.ts index cceb425c..fdb4ab96 100644 --- a/ui/src/stores/interface.ts +++ b/ui/src/stores/interface.ts @@ -35,9 +35,9 @@ const interfaceSetting = create<InterfaceType>((set) => ({ gravatar_base_url: '', }, update: (params) => - set(() => { + set((state) => { return { - interface: params, + interface: { ...state.interface, ...params }, }; }), })); diff --git a/ui/src/stores/loginSetting.ts b/ui/src/stores/loginSetting.ts index 73fd4880..7acf765e 100644 --- a/ui/src/stores/loginSetting.ts +++ b/ui/src/stores/loginSetting.ts @@ -29,7 +29,6 @@ interface IType { const loginSetting = create<IType>((set) => ({ login: { allow_new_registrations: true, - login_required: false, allow_email_registrations: true, allow_email_domains: [], allow_password_login: true, diff --git a/ui/src/stores/siteInfo.ts b/ui/src/stores/siteInfo.ts index 725546d6..c529cc62 100644 --- a/ui/src/stores/siteInfo.ts +++ b/ui/src/stores/siteInfo.ts @@ -39,6 +39,8 @@ const defaultUsersConf: AdminSettingsUsers = { allow_update_location: false, allow_update_username: false, allow_update_website: false, + default_avatar: 'system', + gravatar_base_url: '', }; const siteInfo = create<SiteInfoType>((set) => ({ @@ -48,7 +50,6 @@ const siteInfo = create<SiteInfoType>((set) => ({ short_description: '', site_url: '', contact_email: '', - check_update: true, permalink: 1, }, users: defaultUsersConf, diff --git a/ui/src/stores/siteLegal.ts b/ui/src/stores/siteSecurity.ts similarity index 75% rename from ui/src/stores/siteLegal.ts rename to ui/src/stores/siteSecurity.ts index 29a26ea3..e4e5bb52 100644 --- a/ui/src/stores/siteLegal.ts +++ b/ui/src/stores/siteSecurity.ts @@ -19,12 +19,20 @@ import { create } from 'zustand'; -interface LealStore { +interface SecurityStore { + login_required: boolean; + check_update: boolean; external_content_display: string; - update: (params: { external_content_display: string }) => void; + update: (params: { + external_content_display: string; + check_update: boolean; + login_required: boolean; + }) => void; } -const siteLealStore = create<LealStore>((set) => ({ +const siteSecurityStore = create<SecurityStore>((set) => ({ + login_required: false, + check_update: true, external_content_display: 'always_display', update: (params) => set((state) => { @@ -35,4 +43,4 @@ const siteLealStore = create<LealStore>((set) => ({ }), })); -export default siteLealStore; +export default siteSecurityStore; diff --git a/ui/src/stores/writeSetting.ts b/ui/src/stores/writeSetting.ts index 9f6542d2..576979a2 100644 --- a/ui/src/stores/writeSetting.ts +++ b/ui/src/stores/writeSetting.ts @@ -19,11 +19,17 @@ import { create } from 'zustand'; -import { AdminSettingsWrite } from '@/common/interface'; +import { + AdminSettingsWrite, + AdminQuestionSetting, + AdminTagsSetting, +} from '@/common/interface'; interface IProps { - write: AdminSettingsWrite; - update: (params: AdminSettingsWrite) => void; + write: AdminSettingsWrite & AdminQuestionSetting & AdminTagsSetting; + update: ( + params: AdminSettingsWrite | AdminQuestionSetting | AdminTagsSetting, + ) => void; } const Index = create<IProps>((set) => ({ diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index 24064669..6e6a6d80 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -30,7 +30,7 @@ import { loginToContinueStore, pageTagStore, writeSettingStore, - siteLealStore, + siteSecurityStore, } from '@/stores'; import { RouteAlias } from '@/router/alias'; import { @@ -263,8 +263,8 @@ export const singUpAgent = () => { export const shouldLoginRequired = () => { const gr: TGuardResult = { ok: true }; - const loginSetting = loginSettingStore.getState().login; - if (!loginSetting.login_required) { + const { login_required } = siteSecurityStore.getState(); + if (!login_required) { return gr; } const us = deriveLoginState(); @@ -382,12 +382,11 @@ export const initAppSettingsStore = async () => { themeSettingStore.getState().update(appSettings.theme); seoSettingStore.getState().update(appSettings.site_seo); writeSettingStore.getState().update({ - restrict_answer: appSettings.site_write.restrict_answer, - ...appSettings.site_write, - }); - siteLealStore.getState().update({ - external_content_display: appSettings.site_legal.external_content_display, + ...appSettings.site_advanced, + ...appSettings.site_questions, + ...appSettings.site_tags, }); + siteSecurityStore.getState().update(appSettings.site_security); } };
