feat(ui): admin module update

This commit is contained in:
robin 2022-12-07 18:15:27 +08:00
parent 4f977549db
commit 3c6151b806
11 changed files with 766 additions and 29 deletions

View File

@ -976,6 +976,10 @@ ui:
tos: Terms of Service
privacy: Privacy
seo: SEO
customize: Customize
themes: Themes
css_and_html: CSS/HTML
login: Login
admin:
admin_header:
title: Admin
@ -1079,6 +1083,29 @@ ui:
change_status: Change status
change_role: Change role
show_logs: Show logs
add_user: Add user
new_password_modal:
title: Set new password
form:
fields:
password:
label: Password
btn_cancel: Cancel
btn_submit: Submit
user_modal:
title: Add new user
form:
fields:
display_name:
label: Display Name
email:
label: Email
msg: Email is not valid.
password:
label: Password
btn_cancel: Cancel
btn_submit: Submit
questions:
page_title: Questions
normal: Normal
@ -1230,6 +1257,41 @@ ui:
robots:
label: robots.txt
text: This will permanently override any related site settings.
themes:
page_title: Themes
themes:
label: Themes
text: Select an existing theme.
navbar_style:
label: Navbar Style
text: Select an existing theme.
primary_color:
label: Primary Color
text: Modify the colors used by your themes
css_and_html:
page_title: CSS and HTML
custom_css:
label: Custom CSS
text: This will insert as <link>
head:
label: Head
text: This will insert before </head>
header:
label: Header
text: This will insert after <body>
footer:
label: Footer
text: This will insert before </html>.
login:
page_title: Login
membership:
label: Membership
labelAlias: Allow new registrations
text: Turn off to prevent anyone from creating a new account.
private:
label: Private
text: Only logged in users can access this community.
form:
empty: cannot be empty
invalid: is invalid

View File

@ -53,6 +53,17 @@ export const ADMIN_NAV_MENUS = [
name: 'flags',
// badgeContent: 5,
},
{
name: 'customize',
children: [
{
name: 'themes',
},
{
name: 'css_and_html',
},
],
},
{
name: 'settings',
children: [
@ -63,6 +74,7 @@ export const ADMIN_NAV_MENUS = [
{ name: 'legal' },
{ name: 'write' },
{ name: 'seo' },
{ name: 'login' },
],
},
];

View File

@ -1,4 +1,8 @@
import { FC } from 'react';
import {
ForwardRefRenderFunction,
forwardRef,
useImperativeHandle,
} from 'react';
import { Form, Button, Stack } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
@ -69,8 +73,13 @@ interface IProps {
schema: JSONSchema;
uiSchema?: UISchema;
formData?: Type.FormDataType;
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit: (e: React.FormEvent) => void;
onSubmit?: (e: React.FormEvent) => void;
}
interface IRef {
validator: () => Promise<boolean>;
}
/**
@ -81,13 +90,17 @@ interface IProps {
* @param onChange change event
* @param onSubmit submit event
*/
const SchemaForm: FC<IProps> = ({
schema,
uiSchema = {},
formData = {},
onChange,
onSubmit,
}) => {
const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
{
schema,
uiSchema = {},
formData = {},
onChange,
onSubmit,
hiddenSubmit = false,
},
ref,
) => {
const { t } = useTranslation('translation', {
keyPrefix: 'form',
});
@ -188,9 +201,7 @@ const SchemaForm: FC<IProps> = ({
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validator = async (): Promise<boolean> => {
const errors = requiredValidator();
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
@ -207,7 +218,7 @@ const SchemaForm: FC<IProps> = ({
if (onChange instanceof Function) {
onChange({ ...formData });
}
return;
return false;
}
const syncErrors = await syncValidator();
if (syncErrors.length > 0) {
@ -223,8 +234,18 @@ const SchemaForm: FC<IProps> = ({
if (onChange instanceof Function) {
onChange({ ...formData });
}
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isValid = await validator();
if (!isValid) {
return;
}
Object.keys(formData).forEach((key) => {
formData[key].isInvalid = false;
formData[key].errorMsg = '';
@ -232,7 +253,9 @@ const SchemaForm: FC<IProps> = ({
if (onChange instanceof Function) {
onChange(formData);
}
onSubmit(e);
if (onSubmit instanceof Function) {
onSubmit(e);
}
};
const handleUploadChange = (name: string, value: string) => {
@ -260,6 +283,10 @@ const SchemaForm: FC<IProps> = ({
}
};
useImperativeHandle(ref, () => ({
validator,
}));
return (
<Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => {
@ -312,7 +339,7 @@ const SchemaForm: FC<IProps> = ({
required
type={widget}
name={key}
id={String(item)}
id={`form-${String(item)}`}
label={properties[key].enumNames?.[index]}
checked={formData[key]?.value === item}
feedback={formData[key]?.errorMsg}
@ -342,7 +369,7 @@ const SchemaForm: FC<IProps> = ({
<Form.Label>{title}</Form.Label>
<Form.Check
required
id={title}
id={`switch-${title}`}
name={key}
type="switch"
label={label}
@ -419,7 +446,7 @@ const SchemaForm: FC<IProps> = ({
if (widget === 'textarea') {
return (
<Form.Group
controlId={key}
controlId={`form-${key}`}
key={key}
className={classnames('mb-3', formData[key].hidden && 'd-none')}>
<Form.Label>{title}</Form.Label>
@ -468,9 +495,11 @@ const SchemaForm: FC<IProps> = ({
</Form.Group>
);
})}
<Button variant="primary" type="submit">
{t('btn_submit')}
</Button>
{!hiddenSubmit && (
<Button variant="primary" type="submit">
{t('btn_submit')}
</Button>
)}
</Form>
);
};
@ -488,4 +517,4 @@ export const initFormData = (schema: JSONSchema): Type.FormDataType => {
return formData;
};
export default SchemaForm;
export default forwardRef(SchemaForm);

View File

@ -6,6 +6,8 @@ import useChangeModal from './useChangeModal';
import useEditStatusModal from './useEditStatusModal';
import useChangeUserRoleModal from './useChangeUserRoleModal';
import useHeadInfo from './useHeadInfo';
import useUserModal from './useUserModal';
import useChangePasswordModal from './useChangePasswordModal';
export {
useTagModal,
@ -16,4 +18,6 @@ export {
useEditStatusModal,
useChangeUserRoleModal,
useHeadInfo,
useUserModal,
useChangePasswordModal,
};

View File

@ -0,0 +1,130 @@
import { useLayoutEffect, useState, useRef } from 'react';
import { Modal, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
import type * as Type from '@/common/interface';
import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);
interface IProps {
title?: string;
onConfirm?: (formData: any) => void;
}
const useChangePasswordModal = (props: IProps = {}) => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.users.new_password_modal',
});
const { title = t('title'), onConfirm } = props;
const [visible, setVisibleState] = useState(false);
const schema: JSONSchema = {
title: t('title'),
required: ['password'],
properties: {
new_password: {
type: 'string',
title: t('form.fields.password.label'),
},
},
};
const uiSchema: UISchema = {
new_password: {
'ui:options': {
type: 'password',
},
},
};
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
);
const formRef = useRef<{
validator: () => Promise<boolean>;
}>(null);
const onClose = () => {
setVisibleState(false);
};
const onShow = () => {
setVisibleState(true);
};
const handleSubmit = async (event) => {
event.preventDefault();
event.stopPropagation();
const isValid = await formRef.current?.validator();
if (!isValid) {
return;
}
if (onConfirm instanceof Function) {
onConfirm({
slug_name: formData.slugName.value,
display_name: formData.displayName.value,
original_text: formData.description.value,
});
setFormData({
displayName: {
value: '',
isInvalid: false,
errorMsg: '',
},
slugName: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
}
onClose();
};
const handleOnChange = (data) => {
setFormData(data);
};
useLayoutEffect(() => {
root.render(
<Modal show={visible} title={title} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title as="h5">{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<SchemaForm
ref={formRef}
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={handleOnChange}
hiddenSubmit
/>
</Modal.Body>
<Modal.Footer>
<Button variant="link" onClick={() => onClose()}>
{t('btn_cancel')}
</Button>
<Button variant="primary" onClick={handleSubmit}>
{t('btn_submit')}
</Button>
</Modal.Footer>
</Modal>,
);
});
return {
onClose,
onShow,
};
};
export default useChangePasswordModal;

View File

@ -0,0 +1,150 @@
import { useLayoutEffect, useState, useRef } from 'react';
import { Modal, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
import pattern from '@/common/pattern';
import type * as Type from '@/common/interface';
import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);
interface IProps {
title?: string;
onConfirm?: (formData: any) => void;
}
const useAddUserModal = (props: IProps = {}) => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.users.user_modal',
});
const { title = t('title'), onConfirm } = props;
const [visible, setVisibleState] = useState(false);
const schema: JSONSchema = {
title: t('title'),
required: ['display_name', 'email', 'password'],
properties: {
display_name: {
type: 'string',
title: t('form.fields.display_name.label'),
},
email: {
type: 'string',
title: t('form.fields.email.label'),
},
password: {
type: 'string',
title: t('form.fields.password.label'),
},
},
};
const uiSchema: UISchema = {
email: {
'ui:options': {
type: 'email',
validator: (value) => {
if (value && !pattern.email.test(value)) {
return t('form.fields.email.msg');
}
return true;
},
},
},
password: {
'ui:options': {
type: 'password',
},
},
};
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
);
const formRef = useRef<{
validator: () => Promise<boolean>;
}>(null);
const onClose = () => {
setVisibleState(false);
};
const onShow = () => {
setVisibleState(true);
};
const handleSubmit = async (event) => {
event.preventDefault();
event.stopPropagation();
const isValid = await formRef.current?.validator();
if (!isValid) {
return;
}
if (onConfirm instanceof Function) {
onConfirm({
slug_name: formData.slugName.value,
display_name: formData.displayName.value,
original_text: formData.description.value,
});
setFormData({
displayName: {
value: '',
isInvalid: false,
errorMsg: '',
},
slugName: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
}
onClose();
};
const handleOnChange = (data) => {
setFormData(data);
};
useLayoutEffect(() => {
root.render(
<Modal show={visible} title={title} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title as="h5">{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<SchemaForm
ref={formRef}
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={handleOnChange}
hiddenSubmit
/>
</Modal.Body>
<Modal.Footer>
<Button variant="link" onClick={() => onClose()}>
{t('btn_cancel')}
</Button>
<Button variant="primary" onClick={handleSubmit}>
{t('btn_submit')}
</Button>
</Modal.Footer>
</Modal>,
);
});
return {
onClose,
onShow,
};
};
export default useAddUserModal;

View File

@ -0,0 +1,119 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getSeoSetting, putSeoSetting } from '@/services';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useToast } from '@/hooks';
import { handleFormError } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.css_and_html',
});
const Toast = useToast();
const schema: JSONSchema = {
title: t('page_title'),
properties: {
custom_css: {
type: 'string',
title: t('custom_css.label'),
description: t('custom_css.text'),
},
head: {
type: 'string',
title: t('head.label'),
description: t('head.text'),
},
header: {
type: 'string',
title: t('header.label'),
description: t('header.text'),
},
footer: {
type: 'string',
title: t('footer.label'),
description: t('footer.text'),
},
},
};
const uiSchema: UISchema = {
custom_css: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
head: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
header: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
footer: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsSeo = {
robots: formData.robots.value,
};
putSeoSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getSeoSetting().then((setting) => {
if (setting) {
const formMeta = { ...formData };
formMeta.robots.value = setting.robots;
setFormData(formMeta);
}
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
formData={formData}
onSubmit={onSubmit}
uiSchema={uiSchema}
onChange={handleOnChange}
/>
</>
);
};
export default Index;

View File

@ -0,0 +1,93 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getSeoSetting, putSeoSetting } from '@/services';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useToast } from '@/hooks';
import { handleFormError } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.login',
});
const Toast = useToast();
const schema: JSONSchema = {
title: t('page_title'),
properties: {
membership: {
type: 'boolean',
title: t('membership.label'),
label: t('membership.labelAlias'),
description: t('membership.text'),
default: true,
},
private: {
type: 'string',
title: t('private.label'),
description: t('private.text'),
},
},
};
const uiSchema: UISchema = {
membership: {
'ui:widget': 'switch',
},
private: {
'ui:widget': 'switch',
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsSeo = {
robots: formData.robots.value,
};
putSeoSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getSeoSetting().then((setting) => {
if (setting) {
const formMeta = { ...formData };
formMeta.robots.value = setting.robots;
setFormData(formMeta);
}
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
formData={formData}
onSubmit={onSubmit}
uiSchema={uiSchema}
onChange={handleOnChange}
/>
</>
);
};
export default Index;

View File

@ -0,0 +1,105 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getSeoSetting, putSeoSetting } from '@/services';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useToast } from '@/hooks';
import { handleFormError } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.themes',
});
const Toast = useToast();
const schema: JSONSchema = {
title: t('page_title'),
properties: {
themes: {
type: 'string',
title: t('themes.label'),
description: t('themes.text'),
enum: ['default'],
enumNames: ['Default'],
},
navbar_style: {
type: 'string',
title: t('navbar_style.label'),
description: t('navbar_style.text'),
enum: ['colored', 'light'],
enumNames: ['Colored', 'Light'],
},
primary_color: {
type: 'string',
title: t('primary_color.label'),
description: t('primary_color.text'),
},
},
};
const uiSchema: UISchema = {
themes: {
'ui:widget': 'select',
},
navbar_style: {
'ui:widget': 'select',
},
primary_color: {
'ui:options': {
type: 'color',
},
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsSeo = {
robots: formData.robots.value,
};
putSeoSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getSeoSetting().then((setting) => {
if (setting) {
const formMeta = { ...formData };
formMeta.robots.value = setting.robots;
setFormData(formMeta);
}
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
formData={formData}
onSubmit={onSubmit}
uiSchema={uiSchema}
onChange={handleOnChange}
/>
</>
);
};
export default Index;

View File

@ -1,5 +1,5 @@
import { FC } from 'react';
import { Form, Table, Dropdown } from 'react-bootstrap';
import { Form, Table, Dropdown, Button } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -14,7 +14,13 @@ import {
Icon,
} from '@/components';
import * as Type from '@/common/interface';
import { useChangeModal, useChangeUserRoleModal, useToast } from '@/hooks';
import {
useUserModal,
useChangeModal,
useChangeUserRoleModal,
useChangePasswordModal,
useToast,
} from '@/hooks';
import { useQueryUsers } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import { formatCount } from '@/utils';
@ -44,6 +50,8 @@ const Users: FC = () => {
const curQuery = urlSearchParams.get('query') || '';
const currentUser = loggedUserInfoStore((state) => state.user);
const Toast = useToast();
const userModal = useUserModal();
const changePasswordModal = useChangePasswordModal();
const {
data,
isLoading,
@ -99,12 +107,21 @@ const Users: FC = () => {
<>
<h3 className="mb-4">{t('title')}</h3>
<div className="d-flex justify-content-between align-items-center mb-3">
<QueryGroup
data={UserFilterKeys}
currentSort={curFilter}
sortKey="filter"
i18nKeyPrefix="admin.users"
/>
<div>
<Button
className="me-3"
variant="outline-primary"
size="sm"
onClick={() => userModal.onShow()}>
{t('add_user')}
</Button>
<QueryGroup
data={UserFilterKeys}
currentSort={curFilter}
sortKey="filter"
i18nKeyPrefix="admin.users"
/>
</div>
<Form.Control
size="sm"
@ -184,6 +201,10 @@ const Users: FC = () => {
<Icon name="three-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
onClick={() => changePasswordModal.onShow()}>
{t('set_new_password')}
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleAction('status', user)}>
{t('change_status')}

View File

@ -237,6 +237,14 @@ const routes: RouteNode[] = [
path: 'flags',
page: 'pages/Admin/Flags',
},
{
path: 'themes',
page: 'pages/Admin/Themes',
},
{
path: 'css_and_html',
page: 'pages/Admin/CssAndHtml',
},
{
path: 'general',
page: 'pages/Admin/General',
@ -277,6 +285,10 @@ const routes: RouteNode[] = [
path: 'seo',
page: 'pages/Admin/Seo',
},
{
path: 'login',
page: 'pages/Admin/Login',
},
],
},
// for review