diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index c7ba2b28..a330841d 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1164,8 +1164,9 @@ ui: seo: SEO customize: Customize themes: Themes - css-html: CSS/HTML + css_html: CSS/HTML login: Login + privileges: Privileges plugins: Plugins installed_plugins: Installed Plugins website_welcome: Welcome to {{site_name}} @@ -1368,9 +1369,6 @@ ui: label: Timezone msg: Timezone cannot be empty. text: Choose a city in the same timezone as you. - avatar: - label: Default Avatar - text: For users without a custom avatar of their own. smtp: page_title: SMTP from_email: @@ -1514,7 +1512,30 @@ ui: deactivate: Deactivate activate: Activate settings: Settings - + settings_users: + title: Users + avatar: + label: Default Avatar + text: For users without a custom avatar of their own. + profile_editable: + title: Profile Editable + allow_update_display_name: + label: Allow users to change their display name + allow_update_username: + label: Allow users to change their username + allow_update_avatar: + label: Allow users to change their profile image + allow_update_bio: + label: Allow users to change their about me + allow_update_website: + label: Allow users to change their website + allow_update_location: + label: Allow users to change their location + privilege: + title: Privileges + level: + label: Reputation required level + text: Choose the reputation required for the privileges form: optional: (optional) diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 032c2103..f4bf831f 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { 'react/no-unescaped-entities': 'off', 'react/require-default-props': 'off', 'arrow-body-style': 'off', + "global-require": "off", 'react/prop-types': 0, 'react/no-danger': 'off', 'jsx-a11y/no-static-element-interactions': 'off', diff --git a/ui/src/assets/images/carousel-wecom-1.jpg b/ui/src/assets/images/carousel-wecom-1.jpg new file mode 100644 index 00000000..4dac6292 Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-1.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-2.jpg b/ui/src/assets/images/carousel-wecom-2.jpg new file mode 100644 index 00000000..618db5af Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-2.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-3.jpg b/ui/src/assets/images/carousel-wecom-3.jpg new file mode 100644 index 00000000..c8c7abb4 Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-3.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-4.jpg b/ui/src/assets/images/carousel-wecom-4.jpg new file mode 100644 index 00000000..7f581fb4 Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-4.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-5.jpg b/ui/src/assets/images/carousel-wecom-5.jpg new file mode 100644 index 00000000..e4068ebe Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-5.jpg differ diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 7a540ac9..619f019b 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -74,7 +74,8 @@ export const ADMIN_NAV_MENUS = [ name: 'themes', }, { - name: 'css-html', + name: 'css_html', + path: 'css-html', }, ], }, @@ -89,6 +90,8 @@ export const ADMIN_NAV_MENUS = [ { name: 'write' }, { name: 'seo' }, { name: 'login' }, + { name: 'users', path: 'settings-users' }, + { name: 'privileges' }, ], }, { @@ -96,6 +99,7 @@ export const ADMIN_NAV_MENUS = [ children: [ { name: 'installed_plugins', + path: 'installed-plugins', }, ], }, diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8fc79f91..0f134e89 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -312,7 +312,6 @@ export interface HelmetUpdate extends Omit { export interface AdminSettingsInterface { language: string; time_zone?: string; - default_avatar?: string; } export interface AdminSettingsSmtp { diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index 472a3e63..4e64611f 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -18,12 +18,12 @@ function MenuNode({ }) { const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); const isLeaf = !menu.children.length; - const href = isLeaf ? `${path}${menu.name}` : '#'; + const href = isLeaf ? `${path}${menu.path}` : '#'; return ( - + { callback(evt, menu, href, isLeaf); @@ -31,7 +31,7 @@ function MenuNode({ href={href} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, 'link-dark': activeKey !== menu.name }, + { expanding, 'link-dark': activeKey !== menu.path }, )}> {menu.displayName ? menu.displayName : t(menu.name)} @@ -44,7 +44,7 @@ function MenuNode({ )} {menu.children.length ? ( - + <> {menu.children.map((leaf) => { return ( @@ -53,7 +53,7 @@ function MenuNode({ callback={callback} activeKey={activeKey} path={path} - key={leaf.name} + key={leaf.path} /> ); })} @@ -73,17 +73,24 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { const pathMatch = useMatch(`${path}*`); // auto set menu fields menus.forEach((m) => { + if (!m.path) { + m.path = m.name; + } if (!Array.isArray(m.children)) { m.children = []; } m.children.forEach((sm) => { + if (!sm.path) { + sm.path = sm.name; + } if (!Array.isArray(sm.children)) { sm.children = []; } }); }); + const splat = pathMatch && pathMatch.params['*']; - let activeKey = menus[0].name; + let activeKey = menus[0].path; if (splat) { activeKey = splat; } @@ -92,10 +99,10 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { menus.forEach((li) => { if (li.children.length) { const matchedChild = li.children.find((el) => { - return el.name === activeKey; + return el.path === activeKey; }); if (matchedChild) { - openKey = li.name; + openKey = li.path; } } }); @@ -111,7 +118,7 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { navigate(href); } } else { - setOpenKey(openKey === menu.name ? '' : menu.name); + setOpenKey(openKey === menu.path ? '' : menu.path); } }; useEffect(() => { @@ -127,8 +134,8 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { path={path} callback={menuClick} activeKey={activeKey} - expanding={openKey === li.name} - key={li.name} + expanding={openKey === li.path} + key={li.path} /> ); })} diff --git a/ui/src/components/HttpErrorContent/index.tsx b/ui/src/components/HttpErrorContent/index.tsx index 51ce4bcc..65c649db 100644 --- a/ui/src/components/HttpErrorContent/index.tsx +++ b/ui/src/components/HttpErrorContent/index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { usePageTags } from '@/hooks'; -const Index = ({ httpCode = '', errMsg = '' }) => { +const Index = ({ httpCode = '', errMsg = '', showErroCode = true }) => { const { t } = useTranslation('translation', { keyPrefix: 'page_error' }); useEffect(() => { // auto height of container @@ -31,7 +31,9 @@ const Index = ({ httpCode = '', errMsg = '' }) => { style={{ fontSize: '120px', lineHeight: 1.2 }}> (=‘x‘=) -

{t('http_error', { code: httpCode })}

+ {showErroCode && ( +

{t('http_error', { code: httpCode })}

+ )}
{errMsg || t(`desc_${httpCode}`)}
diff --git a/ui/src/components/SchemaForm/components/Check.tsx b/ui/src/components/SchemaForm/components/Check.tsx new file mode 100644 index 00000000..9936e5de --- /dev/null +++ b/ui/src/components/SchemaForm/components/Check.tsx @@ -0,0 +1,47 @@ +import React, { FC } from 'react'; +import { Form, Stack } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + type: 'radio' | 'checkbox'; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + enumValues: (string | boolean | number)[]; + enumNames: string[]; + formData: Type.FormDataType; +} +const Index: FC = ({ + type = 'radio', + fieldName, + onChange, + enumValues, + enumNames, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + + {enumValues?.map((item, index) => { + return ( + onChange(evt, index)} + /> + ); + })} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Input.tsx b/ui/src/components/SchemaForm/components/Input.tsx new file mode 100644 index 00000000..3b6bd862 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Input.tsx @@ -0,0 +1,34 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + type: string | undefined; + placeholder: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + type = 'text', + placeholder = '', + fieldName, + onChange, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Legend.tsx b/ui/src/components/SchemaForm/components/Legend.tsx new file mode 100644 index 00000000..96b58cf7 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Legend.tsx @@ -0,0 +1,11 @@ +import { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +interface Props { + title: string; +} +const Index: FC = ({ title }) => { + return {title}; +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Select.tsx b/ui/src/components/SchemaForm/components/Select.tsx new file mode 100644 index 00000000..d2f44b26 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Select.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + desc: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent) => void; + enumValues: (string | boolean | number)[]; + enumNames: string[]; + formData: Type.FormDataType; +} +const Index: FC = ({ + desc, + fieldName, + onChange, + enumValues, + enumNames, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + + {enumValues?.map((item, index) => { + return ( + + ); + })} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Switch.tsx b/ui/src/components/SchemaForm/components/Switch.tsx new file mode 100644 index 00000000..1513f29b --- /dev/null +++ b/ui/src/components/SchemaForm/components/Switch.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + title: string; + label: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ title, fieldName, onChange, label, formData }) => { + const fieldObject = formData[fieldName]; + return ( + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Textarea.tsx b/ui/src/components/SchemaForm/components/Textarea.tsx new file mode 100644 index 00000000..6d7c16ed --- /dev/null +++ b/ui/src/components/SchemaForm/components/Textarea.tsx @@ -0,0 +1,39 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import classnames from 'classnames'; + +import type * as Type from '@/common/interface'; + +interface Props { + placeholder: string | undefined; + rows: number | undefined; + className: classnames.Argument; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + placeholder = '', + rows = 3, + className, + fieldName, + onChange, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Timezone.tsx b/ui/src/components/SchemaForm/components/Timezone.tsx new file mode 100644 index 00000000..aca3c7cf --- /dev/null +++ b/ui/src/components/SchemaForm/components/Timezone.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; + +import type * as Type from '@/common/interface'; +import TimeZonePicker from '@/components/TimeZonePicker'; + +interface Props { + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ fieldName, onChange, formData }) => { + const fieldObject = formData[fieldName]; + return ( + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Upload.tsx b/ui/src/components/SchemaForm/components/Upload.tsx new file mode 100644 index 00000000..f979a368 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Upload.tsx @@ -0,0 +1,39 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; +import BrandUpload from '@/components/BrandUpload'; + +interface Props { + type: Type.UploadType | undefined; + acceptType: string | undefined; + fieldName: string; + onChange: (key, val) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + type = 'avatar', + acceptType = '', + fieldName, + onChange, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + onChange(fieldName, value)} + /> + + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/index.ts b/ui/src/components/SchemaForm/components/index.ts new file mode 100644 index 00000000..0db3fa35 --- /dev/null +++ b/ui/src/components/SchemaForm/components/index.ts @@ -0,0 +1,10 @@ +import Legend from './Legend'; +import Select from './Select'; +import Check from './Check'; +import Switch from './Switch'; +import Timezone from './Timezone'; +import Upload from './Upload'; +import Textarea from './Textarea'; +import Input from './Input'; + +export { Legend, Select, Check, Switch, Timezone, Upload, Textarea, Input }; diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx index c57ca5b2..bf8af1fc 100644 --- a/ui/src/components/SchemaForm/index.tsx +++ b/ui/src/components/SchemaForm/index.tsx @@ -1,18 +1,27 @@ -import { +import React, { ForwardRefRenderFunction, forwardRef, useImperativeHandle, useEffect, } from 'react'; -import { Form, Button, Stack } from 'react-bootstrap'; +import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import classnames from 'classnames'; -import BrandUpload from '../BrandUpload'; -import TimeZonePicker from '../TimeZonePicker'; import type * as Type from '@/common/interface'; +import { + Legend, + Select, + Check, + Switch, + Timezone, + Upload, + Textarea, + Input, +} from './components'; + export interface JSONSchema { title: string; description?: string; @@ -31,7 +40,12 @@ export interface JSONSchema { export interface BaseUIOptions { empty?: string; - className?: string | string[]; + // Will be appended to the className of the form component itself + className?: classnames.Argument; + // The className that will be attached to a form field container + fieldClassName?: classnames.Argument; + // Make a form component render into simplified mode + simplify?: boolean; validator?: ( value, formData?, @@ -96,7 +110,8 @@ export type UIWidget = | 'select' | 'upload' | 'timezone' - | 'switch'; + | 'switch' + | 'legend'; export interface UISchema { [key: string]: { 'ui:widget'?: UIWidget; @@ -117,6 +132,13 @@ interface IRef { validator: () => Promise; } +/** + * TODO: + * * Normalize and document `formData[key].hidden && 'd-none'` + * * `handleXXChange` methods are placed in the concrete component + * * Improving field hints for `formData` + */ + /** * json schema form * @param schema json schema @@ -139,9 +161,7 @@ const SchemaForm: ForwardRefRenderFunction = ( const { t } = useTranslation('translation', { keyPrefix: 'form', }); - - const { required = [], properties } = schema; - + const { required = [], properties = {} } = schema || {}; // check required field const excludes = required.filter((key) => !properties[key]); @@ -174,7 +194,6 @@ const SchemaForm: ForwardRefRenderFunction = ( useEffect(() => { setDefaultValueAsDomBehaviour(); }, [formData]); - const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; const data = { @@ -345,219 +364,123 @@ const SchemaForm: ForwardRefRenderFunction = ( useImperativeHandle(ref, () => ({ validator, })); - + if (!formData || !schema || !schema.properties) { + return null; + } return (
{keys.map((key) => { - const { title, description } = properties[key]; + const { + title, + description, + enum: enumValues = [], + enumNames = [], + } = properties[key]; const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } = uiSchema[key] || {}; - if (widget === 'select') { - return ( - - {title} - - {properties[key].enum?.map((item, index) => { - return ( - - ); - })} - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); + const fieldData = formData[key]; + const uiSimplify = widget === 'legend' || uiOpt?.simplify; + let groupClassName: BaseUIOptions['fieldClassName'] = uiOpt?.simplify + ? 'mb-2' + : 'mb-3'; + if (widget === 'legend') { + groupClassName = 'mb-0'; } - - if (widget === 'checkbox' || widget === 'radio') { - return ( - - {title} - - {properties[key].enum?.map((item, index) => { - return ( - handleInputCheck(e, index)} - /> - ); - })} - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); + if (uiOpt?.fieldClassName) { + groupClassName = uiOpt.fieldClassName; } - if (widget === 'switch') { - return ( - - {title} - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - if (widget === 'timezone') { - return ( - - {title} - - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - - if (widget === 'upload') { - const options: UploadOptions = uiSchema[key]?.['ui:options'] || {}; - return ( - - {title} - handleUploadChange(key, value)} - /> - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - - if (widget === 'textarea') { - const options: TextareaOptions = uiSchema[key]?.['ui:options'] || {}; - - return ( - - {title} - - - {formData[key]?.errorMsg} - - - {description && ( - {description} - )} - - ); - } - - const options: InputOptions = uiSchema[key]?.['ui:options'] || {}; - return ( - {title} - + className={classnames( + groupClassName, + formData[key].hidden ? 'd-none' : null, + )}> + {/* Uniform processing `label` */} + {title && !uiSimplify ? {title} : null} + {/* Handling of individual specific controls */} + {widget === 'legend' ? : null} + {widget === 'select' ? ( +