Merge pull request #245 from answerdev/feat/1.0.7/ui

Feat/1.0.7/UI
This commit is contained in:
haitao.jarvis 2023-03-07 16:11:47 +08:00 committed by GitHub
commit 4c1db03a44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 411 additions and 54 deletions

View File

@ -278,6 +278,7 @@ ui:
tag: Tag
tags: Tags
tag_wiki: tag wiki
create_tag: Create Tag
edit_tag: Edit Tag
ask_a_question: Add Question
edit_question: Edit Question
@ -441,7 +442,7 @@ ui:
range: Display name up to 35 characters.
slug_name:
label: URL Slug
desc: 'Must use the character set "a-z", "0-9", "+ # - ."'
desc: URL slug up to 35 characters.
msg:
empty: URL slug cannot be empty.
range: URL slug up to 35 characters.
@ -450,6 +451,7 @@ ui:
label: Description
btn_cancel: Cancel
btn_submit: Submit
btn_post: Post new tag
tag_info:
created_at: Created
edited_at: Edited
@ -1246,6 +1248,9 @@ 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:

View File

@ -595,3 +595,14 @@ export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
'reopened',
'closed',
];
export const SYSTEM_AVATAR_OPTIONS = [
{
label: 'System',
value: 'system',
},
{
label: 'Gravatar',
value: 'gravatar',
},
];

View File

@ -24,13 +24,13 @@ export interface ReportParams {
export interface TagBase {
display_name: string;
slug_name: string;
recommend: boolean;
reserved: boolean;
original_text?: string;
recommend?: boolean;
reserved?: boolean;
}
export interface Tag extends TagBase {
main_tag_slug_name?: string;
original_text?: string;
parsed_text?: string;
}
@ -101,6 +101,11 @@ export interface ModifyUserReq {
website: string;
}
enum RoleId {
User = 1,
Admin = 2,
Moderator = 3,
}
export interface UserInfoBase {
id?: string;
avatar: any;
@ -114,7 +119,7 @@ export interface UserInfoBase {
*/
status?: string;
/** roles */
is_admin?: boolean;
role_id: RoleId;
}
export interface UserInfoRes extends UserInfoBase {
@ -127,7 +132,6 @@ export interface UserInfoRes extends UserInfoBase {
*/
mail_status: number;
language: string;
is_admin: boolean;
e_mail?: string;
[prop: string]: any;
}
@ -297,6 +301,7 @@ export interface HelmetUpdate extends Omit<HelmetBase, 'pageTitle'> {
export interface AdminSettingsInterface {
language: string;
time_zone?: string;
default_avatar?: string;
}
export interface AdminSettingsSmtp {

View File

@ -5,6 +5,7 @@ import { useNavigate, useMatch } from 'react-router-dom';
import classNames from 'classnames';
import { floppyNavigation } from '@/utils';
import { Icon } from '@/components';
import './index.css';
@ -101,10 +102,12 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
const [openKey, setOpenKey] = useState(getOpenKey());
const menuClick = (evt, menu, href, isLeaf) => {
evt.preventDefault();
evt.stopPropagation();
if (isLeaf) {
navigate(href);
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
navigate(href);
}
} else {
setOpenKey(openKey === menu.name ? '' : menu.name);
}

View File

@ -5,6 +5,7 @@ import { NavLink, useNavigate } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { Avatar, Icon } from '@/components';
import { floppyNavigation } from '@/utils';
interface Props {
redDot: Type.NotificationStatus | undefined;
@ -16,10 +17,12 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleLinkClick = (evt) => {
evt.preventDefault();
const { href } = evt.currentTarget;
const { pathname } = new URL(href);
navigate(pathname);
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
const { href } = evt.currentTarget;
const { pathname } = new URL(href);
navigate(pathname);
}
};
return (
<>
@ -63,7 +66,7 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
onClick={handleLinkClick}>
{t('header.nav.setting')}
</Dropdown.Item>
{userInfo?.is_admin ? (
{userInfo?.role_id === 2 ? (
<Dropdown.Item href="/admin" onClick={handleLinkClick}>
{t('header.nav.admin')}
</Dropdown.Item>

View File

@ -70,14 +70,17 @@ const Header: FC = () => {
window.location.replace(window.location.href);
};
const onLoginClick = (evt) => {
evt.preventDefault();
if (location.pathname === '/users/login') {
evt.preventDefault();
window.location.reload();
return;
}
floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true });
});
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true });
});
}
};
useEffect(() => {

View File

@ -13,6 +13,7 @@ import {
reopenQuestion,
} from '@/services';
import { tryNormalLogged } from '@/utils/guard';
import { floppyNavigation } from '@/utils';
interface IProps {
type: 'answer' | 'question';
@ -109,6 +110,9 @@ const Index: FC<IProps> = ({
}
};
const handleEdit = (evt, targetUrl) => {
if (!floppyNavigation.shouldProcessLinkClick(evt)) {
return;
}
evt.preventDefault();
let checkObjectId = qid;
if (type === 'answer') {

View File

@ -3,7 +3,7 @@ import { Pagination } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { scrollToDocTop } from '@/utils';
import { scrollToDocTop, floppyNavigation } from '@/utils';
interface Props {
currentPage: number;
@ -48,10 +48,12 @@ const PageItem = ({ page, currentPage, path }: PageItemProps) => {
active={currentPage === page}
href={path}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(path);
scrollToDocTop();
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
e.stopPropagation();
navigate(path);
scrollToDocTop();
}
}}>
{page}
</Pagination.Item>
@ -91,9 +93,11 @@ const Index: FC<Props> = ({
<Pagination.Prev
href={handleParams(currentPage - 1)}
onClick={(e) => {
e.preventDefault();
navigate(handleParams(currentPage - 1));
scrollToDocTop();
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
navigate(handleParams(currentPage - 1));
scrollToDocTop();
}
}}>
{t('prev')}
</Pagination.Prev>
@ -186,9 +190,11 @@ const Index: FC<Props> = ({
disabled={currentPage === totalPage}
href={handleParams(currentPage + 1)}
onClick={(e) => {
e.preventDefault();
navigate(handleParams(currentPage + 1));
scrollToDocTop();
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
navigate(handleParams(currentPage + 1));
scrollToDocTop();
}
}}>
{t('next')}
</Pagination.Next>

View File

@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { floppyNavigation } from '@/utils';
interface Props {
data;
i18nKeyPrefix: string;
@ -39,10 +41,12 @@ const Index: FC<Props> = ({
};
const handleClick = (e, type) => {
e.preventDefault();
const str = handleParams(type);
if (pathname) {
navigate(`${pathname}${str}`);
if (floppyNavigation.shouldProcessLinkClick(e)) {
e.preventDefault();
navigate(`${pathname}${str}`);
}
} else {
setUrlSearchParams(str);
}

View File

@ -7,10 +7,14 @@ import {
FormDataType,
AdminSettingsInterface,
} from '@/common/interface';
import { interfaceStore } from '@/stores';
import { interfaceStore, loggedUserInfoStore } from '@/stores';
import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { DEFAULT_TIMEZONE } from '@/common/constants';
import { updateInterfaceSetting, useInterfaceSetting } from '@/services';
import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants';
import {
updateInterfaceSetting,
useInterfaceSetting,
getLoggedUserInfo,
} from '@/services';
import {
setupAppLanguage,
loadLanguageOptions,
@ -42,6 +46,13 @@ const Interface: FC = () => {
title: t('time_zone.label'),
description: t('time_zone.text'),
},
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),
},
},
};
@ -56,6 +67,11 @@ const Interface: FC = () => {
isInvalid: false,
errorMsg: '',
},
default_avatar: {
value: setting?.default_avatar || 'System',
isInvalid: false,
errorMsg: '',
},
});
const uiSchema: UISchema = {
@ -65,6 +81,9 @@ const Interface: FC = () => {
time_zone: {
'ui:widget': 'timezone',
},
default_avatar: {
'ui:widget': 'select',
},
};
const getLangs = async () => {
const res: LangsType[] = await loadLanguageOptions(true);
@ -97,6 +116,7 @@ const Interface: FC = () => {
const reqParams: AdminSettingsInterface = {
language: formData.language.value,
time_zone: formData.time_zone.value,
default_avatar: formData.default_avatar.value,
};
updateInterfaceSetting(reqParams)
@ -104,6 +124,9 @@ const Interface: FC = () => {
interfaceStore.getState().update(reqParams);
setupAppLanguage();
setupAppTimeZone();
getLoggedUserInfo().then((info) => {
loggedUserInfoStore.getState().update(info);
});
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',

View File

@ -0,0 +1,220 @@
import React, { useState, useRef, useEffect } from 'react';
import { Container, Row, Col, Form, Button, Card } from 'react-bootstrap';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { Editor, EditorRef } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import type * as Type from '@/common/interface';
import { createTag } from '@/services';
import { handleFormError } from '@/utils';
interface FormDataItem {
displayName: Type.FormValue<string>;
slugName: Type.FormValue<string>;
description: Type.FormValue<string>;
}
const initFormData = {
displayName: {
value: '',
isInvalid: false,
errorMsg: '',
},
slugName: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
};
const Index = () => {
const { role_id = 1 } = loggedUserInfoStore((state) => state.user);
const navigate = useNavigate();
const { t } = useTranslation('translation', { keyPrefix: 'tag_modal' });
const [focusType, setForceType] = useState('');
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const [immData] = useState(initFormData);
const [contentChanged, setContentChanged] = useState(false);
const editorRef = useRef<EditorRef>({
getHtml: () => '',
});
usePromptWithUnload({
when: contentChanged,
});
useEffect(() => {
const { displayName, slugName, description } = formData;
const {
displayName: display_name,
slugName: slug_name,
description: original_text,
} = immData;
if (!display_name || !slug_name || !original_text) {
return;
}
if (
display_name.value !== displayName.value ||
slug_name.value !== slugName.value ||
original_text.value !== description.value
) {
setContentChanged(true);
} else {
setContentChanged(false);
}
}, [
formData.displayName.value,
formData.slugName.value,
formData.description.value,
]);
const handleDescriptionChange = (value: string) =>
setFormData({
...formData,
description: { ...formData.description, value },
});
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();
setContentChanged(false);
const params = {
display_name: formData.displayName.value,
slug_name: formData.slugName.value,
original_text: formData.description.value,
};
createTag(params)
.then((res) => {
navigate(`/tags/${res.slug_name}/info`, {
replace: true,
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData, [
{ from: 'display_name', to: 'displayName' },
{ from: 'slug_name', to: 'slugName' },
{ from: 'original_text', to: 'description' },
]);
setFormData({ ...data });
}
});
};
const handleDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
displayName: { ...formData.displayName, value: e.currentTarget.value },
});
};
const handleSlugNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
slugName: { ...formData.slugName, value: e.currentTarget.value },
});
};
usePageTags({
title: t('create_tag', { keyPrefix: 'page_title' }),
});
return (
<Container className="pt-4 mt-2 mb-5 edit-answer-wrap">
<Row className="justify-content-center">
<Col xxl={10} md={12}>
<h3 className="mb-4">{t('title')}</h3>
</Col>
</Row>
<Row className="justify-content-center">
<Col xxl={7} lg={8} sm={12} className="mb-4 mb-md-0">
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="display_name" className="mb-3">
<Form.Label>{t('form.fields.display_name.label')}</Form.Label>
<Form.Control
value={formData.displayName.value}
isInvalid={formData.displayName.isInvalid}
disabled={role_id !== 2}
onChange={handleDisplayNameChange}
/>
<Form.Control.Feedback type="invalid">
{formData.displayName.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="slug_name" className="mb-3">
<Form.Label>{t('form.fields.slug_name.label')}</Form.Label>
<Form.Control
value={formData.slugName.value}
isInvalid={formData.slugName.isInvalid}
disabled={role_id !== 2}
onChange={handleSlugNameChange}
/>
<Form.Text as="div">{t('form.fields.slug_name.desc')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.slugName.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="description" className="mt-4">
<Form.Label>{t('form.fields.desc.label')}</Form.Label>
<Editor
value={formData.description.value}
onChange={handleDescriptionChange}
className={classNames(
'form-control p-0',
focusType === 'description' && 'focus',
)}
onFocus={() => {
setForceType('description');
}}
onBlur={() => {
setForceType('');
}}
ref={editorRef}
/>
<Form.Control
value={formData.description.value}
type="text"
isInvalid={formData.description.isInvalid}
readOnly
hidden
/>
<Form.Control.Feedback type="invalid">
{formData.description.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<div className="mt-3">
<Button type="submit">{t('btn_post')}</Button>
</div>
</Form>
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Card>
<Card.Header>
{t('title', { keyPrefix: 'how_to_format' })}
</Card.Header>
<Card.Body
className="fmt small"
dangerouslySetInnerHTML={{
__html: t('desc', { keyPrefix: 'how_to_format' }),
}}
/>
</Card>
</Col>
</Row>
</Container>
);
};
export default Index;

View File

@ -42,7 +42,7 @@ const initFormData = {
};
const Index = () => {
const { is_admin = false } = loggedUserInfoStore((state) => state.user);
const { role_id = 1 } = loggedUserInfoStore((state) => state.user);
const { tagId } = useParams();
const navigate = useNavigate();
@ -219,7 +219,7 @@ const Index = () => {
<Form.Control
value={formData.displayName.value}
isInvalid={formData.displayName.isInvalid}
disabled={!is_admin}
disabled={role_id !== 2}
onChange={handleDisplayNameChange}
/>
@ -232,7 +232,7 @@ const Index = () => {
<Form.Control
value={formData.slugName.value}
isInvalid={formData.slugName.isInvalid}
disabled={!is_admin}
disabled={role_id !== 2}
onChange={handleSlugNameChange}
/>
<Form.Text as="div">{t('form.fields.slug_name.info')}</Form.Text>

View File

@ -1,6 +1,14 @@
import { useState } from 'react';
import { Container, Row, Col, Card, Button, Form } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import {
Container,
Row,
Col,
Card,
Button,
Form,
Stack,
} from 'react-bootstrap';
import { useSearchParams, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
@ -8,6 +16,7 @@ import { Tag, Pagination, QueryGroup, TagsLoader } from '@/components';
import { formatCount } from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import { useQueryTags, following } from '@/services';
import { loggedUserInfoStore } from '@/stores';
const sortBtns = ['popular', 'name', 'newest'];
@ -15,6 +24,7 @@ const Tags = () => {
const [urlSearch] = useSearchParams();
const { t } = useTranslation('translation', { keyPrefix: 'tags' });
const [searchTag, setSearchTag] = useState('');
const { role_id } = loggedUserInfoStore((_) => _.user);
const page = Number(urlSearch.get('page')) || 1;
const sort = urlSearch.get('sort');
@ -55,17 +65,26 @@ const Tags = () => {
<Col xxl={10} sm={12}>
<h3 className="mb-4">{t('title')}</h3>
<div className="d-flex justify-content-between align-items-center flex-wrap">
<Form>
<Form.Group controlId="formBasicEmail">
<Form.Control
value={searchTag}
placeholder={t('search_placeholder')}
type="text"
onChange={handleChange}
size="sm"
/>
</Form.Group>
</Form>
<Stack direction="horizontal" gap={3}>
<Form>
<Form.Group controlId="formBasicEmail">
<Form.Control
value={searchTag}
placeholder={t('search_placeholder')}
type="text"
onChange={handleChange}
size="sm"
/>
</Form.Group>
</Form>
{role_id === 2 || role_id === 3 ? (
<Link
className="btn btn-outline-primary btn-sm"
to="/tags/create">
{t('title', { keyPrefix: 'tag_modal' })}
</Link>
) : null}
</Stack>
<QueryGroup
data={sortBtns}
currentSort={sort || 'popular'}

View File

@ -15,7 +15,7 @@ import HistoryItem from './components/Item';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
const { qid = '', aid = '', tid = '' } = useParams();
const { is_admin } = loggedUserInfoStore((state) => state.user);
const { role_id } = loggedUserInfoStore((state) => state.user);
const [showVotes, setShowVotes] = useState(false);
const [isLoading, setLoading] = useState(false);
const [timelineData, setTimelineData] = useState<Type.TimelineRes>();
@ -114,7 +114,7 @@ const Index: FC = () => {
data={item}
objectInfo={timelineData?.object_info}
key={item.activity_id}
isAdmin={is_admin}
isAdmin={role_id === 2}
revisionList={revisionList}
/>
);

View File

@ -10,6 +10,7 @@ import {
clearNotificationStatus,
readNotification,
} from '@/services';
import { floppyNavigation } from '@/utils';
import Inbox from './components/Inbox';
import Achievements from './components/Achievements';
@ -45,6 +46,9 @@ const Notifications = () => {
const navigate = useNavigate();
const handleTypeChange = (evt, val) => {
if (!floppyNavigation.shouldProcessLinkClick(evt)) {
return;
}
evt.preventDefault();
if (type === val) {
return;

View File

@ -37,7 +37,7 @@ const Index: FC<Props> = ({ data }) => {
) : (
<span className="link-dark h3 mb-0">{data.display_name}</span>
)}
{data?.is_admin && (
{data?.role_id === 2 && (
<div className="ms-2">
<OverlayTrigger
placement="top"

View File

@ -80,6 +80,13 @@ const routes: RouteNode[] = [
path: 'tags',
page: 'pages/Tags',
},
{
path: 'tags/create',
page: 'pages/Tags/Create',
guard: () => {
return guard.isAdminOrModerator();
},
},
{
path: 'tags/:tagName',
page: 'pages/Tags/Detail',

View File

@ -65,3 +65,8 @@ export const getTagsBySlugName = (slugNames: string) => {
const apiUrl = `/answer/api/v1/tags?tags=${encodeURIComponent(slugNames)}`;
return request.get<Type.TagInfo[]>(apiUrl);
};
export const createTag = (params: Type.TagBase) => {
const apiUrl = '/answer/api/v1/tag';
return request.post<Type.TagInfo>(apiUrl, params);
};

View File

@ -12,6 +12,7 @@ const interfaceSetting = create<InterfaceType>((set) => ({
interface: {
language: DEFAULT_LANG,
time_zone: '',
default_avatar: 'system',
},
update: (params) =>
set(() => {

View File

@ -26,7 +26,7 @@ const initUser: UserInfoRes = {
status: '',
mail_status: 1,
language: 'Default',
is_admin: false,
role_id: 1,
};
const loggedUserInfoStore = create<UserInfoStore>((set) => ({

View File

@ -43,8 +43,27 @@ const navigateToLogin = (callback?: Function) => {
});
};
/**
* Determine if a Link click event should be handled
*/
const shouldProcessLinkClick = (evt) => {
if (evt.defaultPrevented) {
return false;
}
const { target, nodeName } = evt.currentTarget;
if (nodeName.toLowerCase() !== 'a') {
return false;
}
return (
evt.button === 0 &&
(!target || target === '_self') &&
!(evt.metaKey || evt.ctrlKey || evt.shiftKey || evt.altKey)
);
};
export const floppyNavigation = {
differentCurrent,
navigate,
navigateToLogin,
shouldProcessLinkClick,
};

View File

@ -24,6 +24,7 @@ type TLoginState = {
isForbidden: boolean;
isNormal: boolean;
isAdmin: boolean;
isModerator: boolean;
};
export type TGuardResult = {
@ -40,6 +41,7 @@ export const deriveLoginState = (): TLoginState => {
isForbidden: false,
isNormal: false,
isAdmin: false,
isModerator: false,
};
const { user } = loggedUserInfoStore.getState();
if (user.access_token) {
@ -57,9 +59,12 @@ export const deriveLoginState = (): TLoginState => {
if (ls.isActivated && !ls.isForbidden) {
ls.isNormal = true;
}
if (ls.isNormal && user.is_admin === true) {
if (ls.isNormal && user.role_id === 2) {
ls.isAdmin = true;
}
if (ls.isNormal && user.role_id === 3) {
ls.isModerator = true;
}
return ls;
};
@ -174,6 +179,16 @@ export const admin = () => {
return gr;
};
export const isAdminOrModerator = () => {
const gr = logged();
const us = deriveLoginState();
if (gr.ok && !us.isAdmin && !us.isModerator) {
gr.ok = false;
gr.redirect = RouteAlias.home;
}
return gr;
};
export const allowNewRegistration = () => {
const gr: TGuardResult = { ok: true };
const loginSetting = loginSettingStore.getState().login;