Merge branch 'feat/ui-0.7.0' into 'test'

Feat/ui 0.7.0

See merge request opensource/answer!329
This commit is contained in:
贾海涛(龙笛) 2022-12-12 05:38:07 +00:00
commit 1a453bdf72
18 changed files with 980 additions and 111 deletions

View File

@ -728,7 +728,7 @@ ui:
update: update success
update_password: Password changed successfully.
flag_success: Thanks for flagging.
fobidden_operate_self: Forbidden to operate on yourself
forbidden_operate_self: Forbidden to operate on yourself
review: Your revision will show after review.
related_question:
title: Related Questions
@ -1003,6 +1003,10 @@ ui:
tos: Terms of Service
privacy: Privacy
seo: SEO
customize: Customize
themes: Themes
css-html: CSS/HTML
login: Login
admin:
admin_header:
title: Admin
@ -1106,6 +1110,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
@ -1258,6 +1285,42 @@ 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:
title: Membership
label: Allow new registrations
text: Turn off to prevent anyone from creating a new account.
private:
title: Private
label: Login required
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-html',
},
],
},
{
name: 'settings',
children: [
@ -63,6 +74,7 @@ export const ADMIN_NAV_MENUS = [
{ name: 'legal' },
{ name: 'write' },
{ name: 'seo' },
{ name: 'login' },
],
},
];

View File

@ -346,6 +346,28 @@ export interface AdminSettingsSeo {
robots: string;
}
export type themeConfig = {
navbar_style: string;
primary_color: string;
[k: string]: string | number;
};
export interface AdminSettingsTheme {
theme: string;
theme_config: Record<string, themeConfig>;
}
export interface AdminSettingsCustom {
custom_css: string;
custom_head: string;
custom_header: string;
custom_footer: string;
}
export interface AdminSettingsLogin {
allow_new_registrations: boolean;
login_required: boolean;
}
/**
* @description interface for Activity
*/

View File

@ -0,0 +1,7 @@
.collapse-indicator {
transition: all .2s ease;
}
.expanding .collapse-indicator {
transform: rotate(90deg);
}

View File

@ -1,44 +1,63 @@
import React, { FC } from 'react';
import { Accordion, Button, Stack } from 'react-bootstrap';
import React, { FC, useEffect, useState } from 'react';
import { Accordion, Nav } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useNavigate, useMatch } from 'react-router-dom';
import { useAccordionButton } from 'react-bootstrap/AccordionButton';
import classNames from 'classnames';
import { Icon } from '@/components';
import './index.css';
function MenuNode({ menu, callback, activeKey, isLeaf = false }) {
function MenuNode({
menu,
callback,
activeKey,
expanding = false,
path = '/',
}) {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
const accordionClick = useAccordionButton(menu.name);
const menuOnClick = (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (!isLeaf) {
accordionClick(evt);
}
if (typeof callback === 'function') {
callback(menu);
}
};
const isLeaf = !menu.children.length;
const href = isLeaf ? `${path}${menu.name}` : '#';
let menuCls = 'text-start text-dark text-nowrap shadow-none bg-body border-0';
let menuVariant = 'light';
if (activeKey === menu.name) {
menuCls = 'text-start text-white text-nowrap shadow-none';
menuVariant = 'primary';
}
return (
<Button variant={menuVariant} className={menuCls} onClick={menuOnClick}>
<Stack direction="horizontal">
{!isLeaf ? <Icon name="chevron-right" className="me-1" /> : null}
{t(menu.name)}
<Nav.Item key={menu.name}>
<Nav.Link
eventKey={menu.name}
as={isLeaf ? 'a' : 'button'}
onClick={(evt) => {
callback(evt, menu, href, isLeaf);
}}
href={href}
className={classNames(
'text-nowrap d-flex flex-nowrap align-items-center w-100',
{ expanding, 'link-dark': activeKey !== menu.name },
)}>
<span className="me-auto">{t(menu.name)}</span>
{menu.badgeContent ? (
<span className="badge text-bg-dark ms-auto top-0">
{menu.badgeContent}
</span>
<span className="badge text-bg-dark">{menu.badgeContent}</span>
) : null}
</Stack>
</Button>
{!isLeaf && (
<Icon className="collapse-indicator" name="chevron-right" />
)}
</Nav.Link>
{menu.children.length ? (
<Accordion.Collapse eventKey={menu.name} className="ms-3">
<>
{menu.children.map((leaf) => {
return (
<MenuNode
menu={leaf}
callback={callback}
activeKey={activeKey}
path={path}
key={leaf.name}
/>
);
})}
</>
</Accordion.Collapse>
) : null}
</Nav.Item>
);
}
@ -46,12 +65,9 @@ interface AccordionProps {
menus: any[];
path?: string;
}
const AccordionNav: FC<AccordionProps> = ({ menus, path = '/' }) => {
const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
const navigate = useNavigate();
const pathMatch = useMatch(`${path}*`);
if (!menus.length) {
return null;
}
// auto set menu fields
menus.forEach((m) => {
if (!Array.isArray(m.children)) {
@ -68,57 +84,50 @@ const AccordionNav: FC<AccordionProps> = ({ menus, path = '/' }) => {
if (splat) {
activeKey = splat;
}
const menuClick = (clickedMenu) => {
const menuKey = clickedMenu.name;
if (clickedMenu.children.length) {
return;
}
if (activeKey !== menuKey) {
const routePath = `${path}${menuKey}`;
navigate(routePath);
}
};
let defaultOpenKey;
const getOpenKey = () => {
let openKey = '';
menus.forEach((li) => {
if (li.children.length) {
const matchedChild = li.children.find((el) => {
return el.name === activeKey;
});
if (matchedChild) {
defaultOpenKey = li.name;
openKey = li.name;
}
}
});
return openKey;
};
const [openKey, setOpenKey] = useState(getOpenKey());
const menuClick = (evt, menu, href, isLeaf) => {
evt.preventDefault();
evt.stopPropagation();
if (isLeaf) {
navigate(href);
} else {
setOpenKey(openKey === menu.name ? '' : menu.name);
}
};
useEffect(() => {
setOpenKey(getOpenKey());
}, [activeKey]);
return (
<Accordion defaultActiveKey={defaultOpenKey} flush>
<Stack direction="vertical" gap={1}>
<Accordion activeKey={openKey} flush>
<Nav variant="pills" className="flex-column" activeKey={activeKey}>
{menus.map((li) => {
return (
<React.Fragment key={li.name}>
<MenuNode menu={li} callback={menuClick} activeKey={activeKey} />
{li.children.length ? (
<Accordion.Collapse eventKey={li.name} className="ms-4">
<Stack direction="vertical" gap={1}>
{li.children.map((leaf) => {
return (
<MenuNode
menu={leaf}
menu={li}
path={path}
callback={menuClick}
activeKey={activeKey}
isLeaf
key={leaf.name}
expanding={openKey === li.name}
key={li.name}
/>
);
})}
</Stack>
</Accordion.Collapse>
) : null}
</React.Fragment>
);
})}
</Stack>
</Nav>
</Accordion>
);
};

View File

@ -35,7 +35,6 @@ const Index: FC = () => {
if (!tryLoggedAndActivated().ok) {
return null;
}
return isEdit ? (
<Card className="mb-4">
<Card.Header className="text-nowrap d-flex justify-content-between">
@ -80,7 +79,9 @@ const Index: FC = () => {
<>
<div className="text-muted">{t('follow_tag_tip')}</div>
<NavLink className="d-inline-block my-2" to="/tags">
<Button variant="outline-primary">{t('follow_a_tag')}</Button>
<Button size="sm" variant="outline-primary">
{t('follow_a_tag')}
</Button>
</NavLink>
</>
)}

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> = ({
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);
}
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>
);
})}
{!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

@ -5,6 +5,8 @@ import usePageUsers from './usePageUsers';
import useChangeModal from './useChangeModal';
import useEditStatusModal from './useEditStatusModal';
import useChangeUserRoleModal from './useChangeUserRoleModal';
import useUserModal from './useUserModal';
import useChangePasswordModal from './useChangePasswordModal';
import usePageTags from './usePageTags';
export {
@ -15,5 +17,7 @@ export {
useChangeModal,
useEditStatusModal,
useChangeUserRoleModal,
useUserModal,
useChangePasswordModal,
usePageTags,
};

View File

@ -0,0 +1,122 @@
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 [userId, setUserId] = useState('');
const schema: JSONSchema = {
title: t('title'),
required: ['password'],
properties: {
password: {
type: 'string',
title: t('form.fields.password.label'),
},
},
};
const uiSchema: UISchema = {
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 = (user_id: string) => {
setUserId(user_id);
setVisibleState(true);
};
const handleSubmit = async (event) => {
event.preventDefault();
event.stopPropagation();
const isValid = await formRef.current?.validator();
if (!isValid) {
return;
}
if (onConfirm instanceof Function) {
onConfirm({
password: formData.password.value,
user_id: userId,
});
setFormData({
password: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
setUserId('');
}
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({
display_name: formData.display_name.value,
email: formData.email.value,
password: formData.password.value,
});
setFormData({
display_name: {
value: '',
isInvalid: false,
errorMsg: '',
},
email: {
value: '',
isInvalid: false,
errorMsg: '',
},
password: {
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,125 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getPageCustom, putPageCustom } 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'),
},
custom_head: {
type: 'string',
title: t('head.label'),
description: t('head.text'),
},
custom_header: {
type: 'string',
title: t('header.label'),
description: t('header.text'),
},
custom_footer: {
type: 'string',
title: t('footer.label'),
description: t('footer.text'),
},
},
};
const uiSchema: UISchema = {
custom_css: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
custom_head: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
custom_header: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
custom_footer: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsCustom = {
custom_css: formData.custom_css.value,
custom_head: formData.custom_head.value,
custom_header: formData.custom_header.value,
custom_footer: formData.custom_footer.value,
};
putPageCustom(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getPageCustom().then((setting) => {
if (setting) {
const formMeta = { ...formData };
formMeta.custom_css.value = setting.custom_css;
formMeta.custom_head.value = setting.custom_head;
formMeta.custom_header.value = setting.custom_header;
formMeta.custom_footer.value = setting.custom_footer;
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,98 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getLoginSetting, putLoginSetting } 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: {
allow_new_registrations: {
type: 'boolean',
title: t('membership.title'),
label: t('membership.label'),
description: t('membership.text'),
default: true,
},
login_required: {
type: 'boolean',
title: t('private.title'),
label: t('private.label'),
description: t('private.text'),
default: false,
},
},
};
const uiSchema: UISchema = {
allow_new_registrations: {
'ui:widget': 'switch',
},
login_required: {
'ui:widget': 'switch',
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsLogin = {
allow_new_registrations: formData.allow_new_registrations.value,
login_required: formData.login_required.value,
};
putLoginSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getLoginSetting().then((setting) => {
if (setting) {
const formMeta = { ...formData };
formMeta.allow_new_registrations.value =
setting.allow_new_registrations;
formMeta.login_required.value = setting.login_required;
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,127 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getThemeSetting, putThemeSetting } 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'],
default: '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'),
default: '#ffffff',
},
},
};
const uiSchema: UISchema = {
themes: {
'ui:widget': 'select',
},
navbar_style: {
'ui:widget': 'select',
},
primary_color: {
'ui:options': {
type: 'color',
},
},
};
const [themeSetting, setThemeSetting] = useState<Type.AdminSettingsTheme>();
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const themeName = formData.themes.value;
const reqParams: Type.AdminSettingsTheme = {
theme: themeName,
theme_config: {
[themeName]: {
navbar_style: formData.navbar_style.value,
primary_color: formData.primary_color.value,
},
},
};
putThemeSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getThemeSetting().then((setting) => {
if (setting) {
setThemeSetting(setting);
const themeName = setting.theme;
const themeConfig = setting.theme_config[themeName];
const formMeta = { ...formData };
formMeta.themes.value = themeName;
formMeta.navbar_style.value = themeConfig?.navbar_style;
formMeta.primary_color.value = themeConfig?.primary_color;
setFormData({ ...formMeta });
}
});
}, []);
const handleOnChange = (cd) => {
console.log('cd: ', cd);
setFormData(cd);
const themeConfig = themeSetting?.theme_config[cd.themes.value];
if (themeConfig) {
themeConfig.navbar_style = cd.navbar_style.value;
themeConfig.primary_color = cd.primary_color.value;
setThemeSetting({
theme: themeSetting?.theme,
theme_config: themeSetting?.theme_config,
});
}
};
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, Stack } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -14,8 +14,14 @@ import {
Icon,
} from '@/components';
import * as Type from '@/common/interface';
import { useChangeModal, useChangeUserRoleModal, useToast } from '@/hooks';
import { useQueryUsers } from '@/services';
import {
useUserModal,
useChangeModal,
useChangeUserRoleModal,
useChangePasswordModal,
useToast,
} from '@/hooks';
import { useQueryUsers, addUser, updateUserPassword } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import { formatCount } from '@/utils';
@ -66,11 +72,31 @@ const Users: FC = () => {
callback: refreshUsers,
});
const userModal = useUserModal({
onConfirm: (userModel) => {
addUser(userModel).then(() => {
if (/all|staff/.test(curFilter) && curPage === 1) {
refreshUsers();
}
});
},
});
const changePasswordModal = useChangePasswordModal({
onConfirm: (rd) => {
updateUserPassword(rd).then(() => {
Toast.onShow({
msg: t('update_password', { keyPrefix: 'toast' }),
variant: 'success',
});
});
},
});
const handleAction = (type, user) => {
const { user_id, status, role_id, username } = user;
if (username === currentUser.username) {
Toast.onShow({
msg: t('fobidden_operate_self', { keyPrefix: 'toast' }),
msg: t('forbidden_operate_self', { keyPrefix: 'toast' }),
variant: 'warning',
});
return;
@ -88,6 +114,9 @@ const Users: FC = () => {
role_id,
});
}
if (type === 'password') {
changePasswordModal.onShow(user_id);
}
};
const handleFilter = (e) => {
@ -99,12 +128,20 @@ const Users: FC = () => {
<>
<h3 className="mb-4">{t('title')}</h3>
<div className="d-flex justify-content-between align-items-center mb-3">
<Stack direction="horizontal" gap={3}>
<QueryGroup
data={UserFilterKeys}
currentSort={curFilter}
sortKey="filter"
i18nKeyPrefix="admin.users"
/>
<Button
variant="outline-primary"
size="sm"
onClick={() => userModal.onShow()}>
{t('add_user')}
</Button>
</Stack>
<Form.Control
size="sm"
@ -188,6 +225,10 @@ const Users: FC = () => {
<Icon name="three-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
onClick={() => handleAction('password', user)}>
{t('set_new_password')}
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleAction('status', user)}>
{t('change_status')}

View File

@ -17,6 +17,8 @@ const formPaths = [
'legal',
'write',
'seo',
'themes',
'css-html',
];
const Index: FC = () => {

View File

@ -237,6 +237,14 @@ const routes: RouteNode[] = [
path: 'flags',
page: 'pages/Admin/Flags',
},
{
path: 'themes',
page: 'pages/Admin/Themes',
},
{
path: 'css-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

View File

@ -109,3 +109,33 @@ export const getSeoSetting = () => {
export const putSeoSetting = (params: Type.AdminSettingsSeo) => {
return request.put('/answer/admin/api/siteinfo/seo', params);
};
export const getThemeSetting = () => {
return request.get<Type.AdminSettingsTheme>(
'/answer/admin/api/siteinfo/theme',
);
};
export const putThemeSetting = (params: Type.AdminSettingsTheme) => {
return request.put('/answer/admin/api/siteinfo/theme', params);
};
export const getPageCustom = () => {
return request.get<Type.AdminSettingsCustom>(
'/answer/admin/api/siteinfo/custom-css-html',
);
};
export const putPageCustom = (params: Type.AdminSettingsCustom) => {
return request.put('/answer/admin/api/siteinfo/custom-css-html', params);
};
export const getLoginSetting = () => {
return request.get<Type.AdminSettingsLogin>(
'/answer/admin/api/siteinfo/login',
);
};
export const putLoginSetting = (params: Type.AdminSettingsLogin) => {
return request.put('/answer/admin/api/siteinfo/login', params);
};

View File

@ -29,3 +29,18 @@ export const getUserRoles = () => {
export const changeUserRole = (params) => {
return request.put('/answer/admin/api/user/role', params);
};
export const addUser = (params: {
display_name: string;
email: string;
password: string;
}) => {
return request.post('/answer/admin/api/user', params);
};
export const updateUserPassword = (params: {
password: string;
user_id: string;
}) => {
return request.put('/answer/admin/api/user/password', params);
};