feat(navigation): guard route done

This commit is contained in:
haitao(lj) 2022-10-29 20:43:52 +08:00
parent 1ba76183fe
commit a801ff6cda
77 changed files with 656 additions and 411 deletions

View File

@ -1,3 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
extends: ['@commitlint/routes-conventional'],
};

View File

@ -1,8 +1,9 @@
import { RouterProvider } from 'react-router-dom';
import router from '@/router';
import { routes, createBrowserRouter } from '@/router';
function App() {
const router = createBrowserRouter(routes);
return <RouterProvider router={router} />;
}

View File

@ -1,9 +1,9 @@
export const LOGIN_NEED_BACK = [
'/users/login',
'/users/register',
'/users/account-recovery',
'/users/password-reset',
];
export const DEFAULT_LANG = 'en_US';
export const CURRENT_LANG_STORAGE_KEY = '_a_lang__';
export const LOGGED_USER_STORAGE_KEY = '_a_lui_';
export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_';
export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_';
export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_';
export const ADMIN_LIST_STATUS = {
// normal;

View File

@ -108,7 +108,7 @@ export interface UserInfoBase {
*/
status?: string;
/** roles */
is_admin?: true;
is_admin?: boolean;
}
export interface UserInfoRes extends UserInfoBase {

View File

@ -5,11 +5,12 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Icon } from '@answer/components';
import { bookmark, postVote } from '@answer/api';
import { isLogin } from '@answer/utils';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
import { useToast } from '@answer/hooks';
import { tryNormalLogged } from '@/utils/guards';
import { bookmark, postVote } from '@/services';
interface Props {
className?: string;
data: {
@ -32,7 +33,7 @@ const Index: FC<Props> = ({ className, data }) => {
state: data?.collected,
count: data?.collectCount,
});
const { username = '' } = userInfoStore((state) => state.user);
const { username = '' } = loggedUserInfoStore((state) => state.user);
const toast = useToast();
const { t } = useTranslation();
useEffect(() => {
@ -48,7 +49,7 @@ const Index: FC<Props> = ({ className, data }) => {
}, []);
const handleVote = (type: 'up' | 'down') => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
@ -84,7 +85,7 @@ const Index: FC<Props> = ({ className, data }) => {
};
const handleBookmark = () => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
bookmark({

View File

@ -8,18 +8,20 @@ import { unionBy } from 'lodash';
import { marked } from 'marked';
import * as Types from '@answer/common/interface';
import { Modal } from '@answer/components';
import { usePageUsers, useReportModal } from '@answer/hooks';
import { matchedUsers, parseUserInfo } from '@answer/utils';
import { Form, ActionBar, Reply } from './components';
import { tryNormalLogged } from '@/utils/guards';
import {
useQueryComments,
addComment,
deleteComment,
updateComment,
postVote,
} from '@answer/api';
import { Modal } from '@answer/components';
import { usePageUsers, useReportModal } from '@answer/hooks';
import { matchedUsers, parseUserInfo, isLogin } from '@answer/utils';
import { Form, ActionBar, Reply } from './components';
} from '@/services';
import './index.scss';
@ -163,7 +165,7 @@ const Comment = ({ objectId, mode }) => {
};
const handleVote = (id, is_cancel) => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
@ -189,7 +191,7 @@ const Comment = ({ objectId, mode }) => {
};
const handleAction = ({ action }, item) => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
if (action === 'report') {

View File

@ -3,10 +3,11 @@ import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Modal as AnswerModal } from '@answer/components';
import { uploadImage } from '@answer/api';
import ToolItem from '../toolItem';
import { IEditorContext } from '../types';
import { uploadImage } from '@/services';
const Image: FC<IEditorContext> = ({ editor }) => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });

View File

@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import { TagSelector, Tag } from '@answer/components';
import { isLogin } from '@answer/utils';
import { useFollowingTags, followTags } from '@answer/api';
import { tryNormalLogged } from '@/utils/guards';
import { useFollowingTags, followTags } from '@/services';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
@ -32,7 +33,7 @@ const Index: FC = () => {
});
};
if (!isLogin()) {
if (!tryNormalLogged()) {
return null;
}

View File

@ -17,17 +17,22 @@ import {
useLocation,
} from 'react-router-dom';
import { userInfoStore, siteInfoStore, interfaceStore } from '@answer/stores';
import { logout, useQueryNotificationStatus } from '@answer/api';
import Storage from '@answer/utils/storage';
import {
loggedUserInfoStore,
siteInfoStore,
interfaceStore,
} from '@answer/stores';
import NavItems from './components/NavItems';
import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias';
import './index.scss';
const Header: FC = () => {
const navigate = useNavigate();
const { user, clear } = userInfoStore();
const { user, clear } = loggedUserInfoStore();
const { t } = useTranslation();
const [urlSearch] = useSearchParams();
const q = urlSearch.get('q');
@ -42,9 +47,8 @@ const Header: FC = () => {
const handleLogout = async () => {
await logout();
Storage.remove('token');
clear();
navigate('/');
navigate(RouteAlias.home);
};
useEffect(() => {

View File

@ -3,9 +3,10 @@ import { Card, ListGroup, ListGroupItem } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useHotQuestions } from '@answer/api';
import { Icon } from '@answer/components';
import { useHotQuestions } from '@/services';
const HotQuestions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const [questions, setQuestions] = useState<any>([]);

View File

@ -9,6 +9,9 @@ import type {
ImgCodeRes,
} from '@answer/common/interface';
import { CAPTCHA_CODE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
interface IProps {
/** control visible */
visible: boolean;
@ -55,7 +58,7 @@ const Index: React.FC<IProps> = ({
placeholder={t('placeholder')}
isInvalid={captcha.isInvalid}
onChange={(e) => {
localStorage.setItem('captchaCode', e.target.value);
Storage.set(CAPTCHA_CODE_STORAGE_KEY, e.target.value);
handleCaptcha({
captcha_code: {
value: e.target.value,

View File

@ -5,10 +5,11 @@ import { useTranslation } from 'react-i18next';
import { Modal } from '@answer/components';
import { useReportModal, useToast } from '@answer/hooks';
import { deleteQuestion, deleteAnswer } from '@answer/api';
import { isLogin } from '@answer/utils';
import Share from '../Share';
import { deleteQuestion, deleteAnswer } from '@/services';
import { tryNormalLogged } from '@/utils/guards';
interface IProps {
type: 'answer' | 'question';
qid: string;
@ -98,7 +99,7 @@ const Index: FC<IProps> = ({
};
const handleAction = (action) => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
if (action === 'delete') {

View File

@ -3,7 +3,7 @@ import { Row, Col, ListGroup } from 'react-bootstrap';
import { NavLink, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuestionList } from '@answer/api';
import { useQuestionList } from '@/services';
import type * as Type from '@answer/common/interface';
import {
Icon,

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { FacebookShareButton, TwitterShareButton } from 'next-share';
import copy from 'copy-to-clipboard';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
interface IProps {
type: 'answer' | 'question';
@ -15,7 +15,7 @@ interface IProps {
}
const Index: FC<IProps> = ({ type, qid, aid, title }) => {
const user = userInfoStore((state) => state.user);
const user = loggedUserInfoStore((state) => state.user);
const [show, setShow] = useState(false);
const [showTip, setShowTip] = useState(false);
const [canSystemShare, setSystemShareState] = useState(false);

View File

@ -6,7 +6,7 @@ import { marked } from 'marked';
import classNames from 'classnames';
import { useTagModal } from '@answer/hooks';
import { queryTags } from '@answer/api';
import { queryTags } from '@/services';
import type * as Type from '@answer/common/interface';
import './index.scss';

View File

@ -2,14 +2,17 @@ import React, { useState, useEffect } from 'react';
import { Button, Col } from 'react-bootstrap';
import { Trans, useTranslation } from 'react-i18next';
import { resendEmail, checkImgCode } from '@answer/api';
import { resendEmail, checkImgCode } from '@/services';
import { PicAuthCodeModal } from '@answer/components/Modal';
import type {
ImgCodeRes,
ImgCodeReq,
FormDataType,
} from '@answer/common/interface';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
import { CAPTCHA_CODE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
interface IProps {
visible: boolean;
@ -19,7 +22,7 @@ const Index: React.FC<IProps> = ({ visible = false }) => {
const { t } = useTranslation('translation', { keyPrefix: 'inactive' });
const [isSuccess, setSuccess] = useState(false);
const [showModal, setModalState] = useState(false);
const { e_mail } = userInfoStore((state) => state.user);
const { e_mail } = loggedUserInfoStore((state) => state.user);
const [formData, setFormData] = useState<FormDataType>({
captcha_code: {
value: '',
@ -47,7 +50,7 @@ const Index: React.FC<IProps> = ({ visible = false }) => {
}
let obj: ImgCodeReq = {};
if (imgCode.verify) {
const code = localStorage.getItem('captchaCode') || '';
const code = Storage.get(CAPTCHA_CODE_STORAGE_KEY) || '';
obj = {
captcha_code: code,
captcha_id: imgCode.captcha_id,

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
import { changeUserStatus } from '@answer/api';
import { changeUserStatus } from '@/services';
import { Modal as AnswerModal } from '@answer/components';
const div = document.createElement('div');

View File

@ -4,10 +4,11 @@ import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
import { reportList, postReport, closeQuestion, putReport } from '@answer/api';
import { useToast } from '@answer/hooks';
import type * as Type from '@answer/common/interface';
import { reportList, postReport, closeQuestion, putReport } from '@/services';
interface Params {
isBackend?: boolean;
type: Type.ReportType;

View File

@ -6,6 +6,8 @@ import Backend from 'i18next-http-backend';
import en from './locales/en.json';
import zh from './locales/zh_CN.json';
import { DEFAULT_LANG } from '@/common/constants';
i18next
// load translation using http
.use(Backend)
@ -21,7 +23,7 @@ i18next
},
},
// debug: process.env.NODE_ENV === 'development',
fallbackLng: process.env.REACT_APP_LANG || 'en_US',
fallbackLng: process.env.REACT_APP_LANG || DEFAULT_LANG,
interpolation: {
escapeValue: false,
},

View File

@ -3,14 +3,26 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { pullLoggedUser } from '@/utils/guards';
import './i18n/init';
import './index.scss';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
async function bootstrapApp() {
/**
* NOTICE: must pre init logged user info for router
*/
await pullLoggedUser();
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
bootstrapApp();

View File

@ -14,7 +14,7 @@ import {
} from '@answer/components';
import { ADMIN_LIST_STATUS } from '@answer/common/constants';
import { useEditStatusModal } from '@answer/hooks';
import { useAnswerSearch, changeAnswerStatus } from '@answer/api';
import { useAnswerSearch, changeAnswerStatus } from '@/services';
import * as Type from '@answer/common/interface';
import '../index.scss';

View File

@ -12,7 +12,7 @@ import {
} from '@answer/components';
import { useReportModal } from '@answer/hooks';
import * as Type from '@answer/common/interface';
import { useFlagSearch } from '@answer/api';
import { useFlagSearch } from '@/services';
import '../index.scss';

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import type * as Type from '@answer/common/interface';
import { useToast } from '@answer/hooks';
import { siteInfoStore } from '@answer/stores';
import { useGeneralSetting, updateGeneralSetting } from '@answer/api';
import { useGeneralSetting, updateGeneralSetting } from '@/services';
import '../index.scss';

View File

@ -14,7 +14,7 @@ import {
updateInterfaceSetting,
useInterfaceSetting,
useThemeOptions,
} from '@answer/api';
} from '@/services';
import { interfaceStore } from '@answer/stores';
import { UploadImg } from '@answer/components';

View File

@ -18,7 +18,7 @@ import {
useQuestionSearch,
changeQuestionStatus,
deleteQuestion,
} from '@answer/api';
} from '@/services';
import * as Type from '@answer/common/interface';
import '../index.scss';

View File

@ -3,7 +3,7 @@ import { Button, Form, Table, Badge } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQueryUsers } from '@answer/api';
import { useQueryUsers } from '@/services';
import {
Pagination,
FormatTime,

View File

@ -1,60 +1,43 @@
import { FC, useEffect } from 'react';
import { FC, useEffect, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { Helmet, HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import {
userInfoStore,
siteInfoStore,
interfaceStore,
toastStore,
} from '@answer/stores';
import { siteInfoStore, interfaceStore, toastStore } from '@answer/stores';
import { Header, AdminHeader, Footer, Toast } from '@answer/components';
import { useSiteSettings, useCheckUserStatus } from '@answer/api';
import { useSiteSettings } from '@/services';
import Storage from '@/utils/storage';
import { CURRENT_LANG_STORAGE_KEY } from '@/common/constants';
let isMounted = false;
const Layout: FC = () => {
const { siteInfo, update: siteStoreUpdate } = siteInfoStore();
const { update: interfaceStoreUpdate } = interfaceStore();
const { data: siteSettings } = useSiteSettings();
const { data: userStatus } = useCheckUserStatus();
useEffect(() => {
if (siteSettings) {
siteStoreUpdate(siteSettings.general);
interfaceStoreUpdate(siteSettings.interface);
}
}, [siteSettings]);
const updateUser = userInfoStore((state) => state.update);
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const { i18n } = useTranslation();
const closeToast = () => {
toastClear();
};
useEffect(() => {
if (siteSettings) {
siteStoreUpdate(siteSettings.general);
interfaceStoreUpdate(siteSettings.interface);
}
}, [siteSettings]);
if (!isMounted) {
isMounted = true;
const lang = Storage.get('LANG');
const user = Storage.get('userInfo');
if (user) {
updateUser(user);
}
const lang = Storage.get(CURRENT_LANG_STORAGE_KEY);
if (lang) {
i18n.changeLanguage(lang);
}
}
if (userStatus?.status) {
const user = Storage.get('userInfo');
if (userStatus.status !== user.status) {
user.status = userStatus?.status;
updateUser(user);
}
}
return (
<HelmetProvider>
<Helmet>
@ -76,4 +59,4 @@ const Layout: FC = () => {
);
};
export default Layout;
export default memo(Layout);

View File

@ -14,7 +14,7 @@ import {
useQueryRevisions,
postAnswer,
useQueryQuestionByTitle,
} from '@answer/api';
} from '@/services';
import type * as Type from '@answer/common/interface';
import SearchQuestion from './components/SearchQuestion';

View File

@ -11,7 +11,7 @@ import {
FormatTime,
htmlRender,
} from '@answer/components';
import { acceptanceAnswer } from '@answer/api';
import { acceptanceAnswer } from '@/services';
import { scrollTop } from '@answer/utils';
import { AnswerItem } from '@answer/common/interface';

View File

@ -13,7 +13,7 @@ import {
htmlRender,
} from '@answer/components';
import { formatCount } from '@answer/utils';
import { following } from '@answer/api';
import { following } from '@/services';
interface Props {
data: any;

View File

@ -3,16 +3,16 @@ import { Card, ListGroup } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useSimilarQuestion } from '@answer/api';
import { useSimilarQuestion } from '@/services';
import { Icon } from '@answer/components';
import { userInfoStore } from '@/stores';
import { loggedUserInfoStore } from '@/stores';
interface Props {
id: string;
}
const Index: FC<Props> = ({ id }) => {
const { user } = userInfoStore();
const { user } = loggedUserInfoStore();
const { t } = useTranslation('translation', {
keyPrefix: 'related_question',
});

View File

@ -6,7 +6,7 @@ import { marked } from 'marked';
import classNames from 'classnames';
import { Editor, Modal } from '@answer/components';
import { postAnswer } from '@answer/api';
import { postAnswer } from '@/services';
import { FormDataType } from '@answer/common/interface';
interface Props {

View File

@ -2,9 +2,9 @@ import { useEffect, useState } from 'react';
import { Container, Row, Col } from 'react-bootstrap';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { questionDetail, getAnswers } from '@answer/api';
import { questionDetail, getAnswers } from '@/services';
import { Pagination, PageTitle } from '@answer/components';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
import { scrollTop } from '@answer/utils';
import { usePageUsers } from '@answer/hooks';
import type {
@ -37,7 +37,7 @@ const Index = () => {
list: [],
});
const { setUsers } = usePageUsers();
const userInfo = userInfoStore((state) => state.user);
const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username;
const requestAnswers = async () => {
const res = await getAnswers({

View File

@ -11,7 +11,7 @@ import {
useQueryAnswerInfo,
modifyAnswer,
useQueryRevisions,
} from '@answer/api';
} from '@/services';
import type * as Type from '@answer/common/interface';
import './index.scss';

View File

@ -3,8 +3,8 @@ import { useSearchParams, Link } from 'react-router-dom';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { following } from '@answer/api';
import { isLogin } from '@answer/utils';
import { following } from '@/services';
import { tryNormalLogged } from '@/utils/guards';
interface Props {
data;
@ -20,7 +20,7 @@ const Index: FC<Props> = ({ data }) => {
const [followed, setFollowed] = useState(data?.is_follower);
const follow = () => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
following({

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { Pagination, PageTitle } from '@answer/components';
import { useSearch } from '@answer/api';
import { useSearch } from '@/services';
import { Head, SearchHead, SearchItem, Tips, Empty } from './components';

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import * as Type from '@answer/common/interface';
import { PageTitle, FollowingTags } from '@answer/components';
import { useTagInfo, useFollow } from '@answer/api';
import { useTagInfo, useFollow } from '@/services';
import QuestionList from '@/components/QuestionList';
import HotQuestions from '@/components/HotQuestions';

View File

@ -7,8 +7,8 @@ import dayjs from 'dayjs';
import classNames from 'classnames';
import { Editor, EditorRef, PageTitle } from '@answer/components';
import { useTagInfo, modifyTag, useQueryRevisions } from '@answer/api';
import { userInfoStore } from '@answer/stores';
import { useTagInfo, modifyTag, useQueryRevisions } from '@/services';
import { loggedUserInfoStore } from '@answer/stores';
import type * as Type from '@answer/common/interface';
interface FormDataItem {
@ -40,7 +40,7 @@ const initFormData = {
},
};
const Ask = () => {
const { is_admin = false } = userInfoStore((state) => state.user);
const { is_admin = false } = loggedUserInfoStore((state) => state.user);
const { tagId } = useParams();
const navigate = useNavigate();

View File

@ -17,7 +17,7 @@ import {
useQuerySynonymsTags,
saveSynonymsTags,
deleteTag,
} from '@answer/api';
} from '@/services';
const TagIntroduction = () => {
const [isEdit, setEditState] = useState(false);

View File

@ -3,7 +3,7 @@ import { Container, Row, Col, Card, Button, Form } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQueryTags, following } from '@answer/api';
import { useQueryTags, following } from '@/services';
import { Tag, Pagination, PageTitle, QueryGroup } from '@answer/components';
import { formatCount } from '@answer/utils';

View File

@ -2,7 +2,7 @@ import { FC, memo, useEffect, useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { resetPassword, checkImgCode } from '@answer/api';
import { resetPassword, checkImgCode } from '@/services';
import type {
ImgCodeRes,
PasswordResetReq,

View File

@ -2,10 +2,9 @@ import React, { useState, useEffect } from 'react';
import { Container, Col } from 'react-bootstrap';
import { Trans, useTranslation } from 'react-i18next';
import { isLogin } from '@answer/utils';
import SendEmail from './components/sendEmail';
import { tryNormalLogged } from '@/utils/guards';
import { PageTitle } from '@/components';
const Index: React.FC = () => {
@ -19,7 +18,7 @@ const Index: React.FC = () => {
};
useEffect(() => {
isLogin();
tryNormalLogged();
}, []);
return (

View File

@ -1,15 +1,15 @@
import { FC, memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { activateAccount } from '@answer/api';
import { userInfoStore } from '@answer/stores';
import { activateAccount } from '@/services';
import { loggedUserInfoStore } from '@answer/stores';
import { getQueryString } from '@answer/utils';
import { PageTitle } from '@/components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const updateUser = userInfoStore((state) => state.update);
const updateUser = loggedUserInfoStore((state) => state.update);
useEffect(() => {
const code = getQueryString('code');

View File

@ -3,7 +3,7 @@ import { Container, Row, Col } from 'react-bootstrap';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { changeEmailVerify } from '@answer/api';
import { changeEmailVerify } from '@/services';
import { PageTitle } from '@/components';

View File

@ -1,26 +1,30 @@
import React, { FormEvent, useState, useEffect } from 'react';
import { Container, Form, Button, Col } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { login, checkImgCode } from '@answer/api';
import type {
LoginReqParams,
ImgCodeRes,
FormDataType,
} from '@answer/common/interface';
import { PageTitle, Unactivate } from '@answer/components';
import { userInfoStore } from '@answer/stores';
import { isLogin, getQueryString } from '@answer/utils';
import { loggedUserInfoStore } from '@answer/stores';
import { getQueryString } from '@answer/utils';
import { login, checkImgCode } from '@/services';
import { deriveUserStat, tryNormalLogged } from '@/utils/guards';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
import { RouteAlias } from '@/router/alias';
import { PicAuthCodeModal } from '@/components/Modal';
import Storage from '@/utils/storage';
const Index: React.FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const navigate = useNavigate();
const [refresh, setRefresh] = useState(0);
const updateUser = userInfoStore((state) => state.update);
const storeUser = userInfoStore((state) => state.user);
const updateUser = loggedUserInfoStore((state) => state.update);
const storeUser = loggedUserInfoStore((state) => state.user);
const [formData, setFormData] = useState<FormDataType>({
e_mail: {
value: '',
@ -102,15 +106,16 @@ const Index: React.FC = () => {
login(params)
.then((res) => {
updateUser(res);
if (res.mail_status === 2) {
const userStat = deriveUserStat();
if (!userStat.isActivated) {
// inactive
setStep(2);
setRefresh((pre) => pre + 1);
}
if (res.mail_status === 1) {
const path = Storage.get('ANSWER_PATH') || '/';
Storage.remove('ANSWER_PATH');
window.location.replace(path);
} else {
const path =
Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home;
Storage.remove(REDIRECT_PATH_STORAGE_KEY);
navigate(path, { replace: true });
}
setModalState(false);
@ -154,7 +159,7 @@ const Index: React.FC = () => {
if ((storeUser.id && storeUser.mail_status === 2) || isInactive) {
setStep(2);
} else {
isLogin();
tryNormalLogged();
}
}, []);

View File

@ -8,7 +8,7 @@ import {
clearUnreadNotification,
clearNotificationStatus,
readNotification,
} from '@answer/api';
} from '@/services';
import { PageTitle } from '@answer/components';
import Inbox from './components/Inbox';

View File

@ -3,19 +3,19 @@ import { Container, Col, Form, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { replacementPassword } from '@answer/api';
import { userInfoStore } from '@answer/stores';
import { getQueryString, isLogin } from '@answer/utils';
import { loggedUserInfoStore } from '@answer/stores';
import { getQueryString } from '@answer/utils';
import type { FormDataType } from '@answer/common/interface';
import Storage from '@/utils/storage';
import { replacementPassword } from '@/services';
import { tryNormalLogged } from '@/utils/guards';
import { PageTitle } from '@/components';
const Index: React.FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'password_reset' });
const [step, setStep] = useState(1);
const clearUser = userInfoStore((state) => state.clear);
const clearUser = loggedUserInfoStore((state) => state.clear);
const [formData, setFormData] = useState<FormDataType>({
pass: {
value: '',
@ -105,7 +105,6 @@ const Index: React.FC = () => {
.then(() => {
// clear login information then to login page
clearUser();
Storage.remove('token');
setStep(2);
})
.catch((err) => {
@ -118,7 +117,7 @@ const Index: React.FC = () => {
};
useEffect(() => {
isLogin();
tryNormalLogged();
}, []);
return (
<>

View File

@ -4,12 +4,12 @@ import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router-dom';
import { Pagination, FormatTime, PageTitle, Empty } from '@answer/components';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
import {
usePersonalInfoByName,
usePersonalTop,
usePersonalListByTabName,
} from '@answer/api';
} from '@/services';
import {
UserInfo,
@ -30,7 +30,7 @@ const Personal: FC = () => {
const page = searchParams.get('page') || 1;
const order = searchParams.get('order') || 'newest';
const { t } = useTranslation('translation', { keyPrefix: 'personal' });
const sessionUser = userInfoStore((state) => state.user);
const sessionUser = loggedUserInfoStore((state) => state.user);
const isSelf = sessionUser?.username === username;
const { data: userInfo } = usePersonalInfoByName(username);

View File

@ -3,7 +3,7 @@ import { Form, Button, Col } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { register } from '@answer/api';
import { register } from '@/services';
import type { FormDataType } from '@answer/common/interface';
import userStore from '@/stores/userInfo';

View File

@ -3,10 +3,11 @@ import { Container } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { PageTitle, Unactivate } from '@answer/components';
import { isLogin } from '@answer/utils';
import SignUpForm from './components/SignUpForm';
import { tryNormalLogged } from '@/utils/guards';
const Index: React.FC = () => {
const [showForm, setShowForm] = useState(true);
const { t } = useTranslation('translation', { keyPrefix: 'login' });
@ -16,7 +17,7 @@ const Index: React.FC = () => {
};
useEffect(() => {
isLogin();
tryNormalLogged();
}, []);
return (

View File

@ -3,7 +3,7 @@ import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type * as Type from '@answer/common/interface';
import { getUserInfo, changeEmail } from '@answer/api';
import { getLoggedUserInfo, changeEmail } from '@/services';
import { useToast } from '@answer/hooks';
const reg = /(?<=.{2}).+(?=@)/gi;
@ -23,7 +23,7 @@ const Index: FC = () => {
const [userInfo, setUserInfo] = useState<Type.UserInfoRes>();
const toast = useToast();
useEffect(() => {
getUserInfo().then((resp) => {
getLoggedUserInfo().then((resp) => {
setUserInfo(resp);
});
}, []);

View File

@ -2,7 +2,7 @@ import React, { FC, FormEvent, useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { modifyPassword } from '@answer/api';
import { modifyPassword } from '@/services';
import { useToast } from '@answer/hooks';
import type { FormDataType } from '@answer/common/interface';

View File

@ -6,10 +6,11 @@ import dayjs from 'dayjs';
import en from 'dayjs/locale/en';
import zh from 'dayjs/locale/zh-cn';
import { languages } from '@answer/api';
import { languages } from '@/services';
import type { LangsType, FormDataType } from '@answer/common/interface';
import { useToast } from '@answer/hooks';
import { DEFAULT_LANG, CURRENT_LANG_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
const Index = () => {
@ -34,8 +35,8 @@ const Index = () => {
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
Storage.set('LANG', formData.lang.value);
dayjs.locale(formData.lang.value === 'en_US' ? en : zh);
Storage.set(CURRENT_LANG_STORAGE_KEY, formData.lang.value);
dayjs.locale(formData.lang.value === DEFAULT_LANG ? en : zh);
i18n.changeLanguage(formData.lang.value);
toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
@ -45,7 +46,7 @@ const Index = () => {
useEffect(() => {
getLangs();
const lang = Storage.get('LANG');
const lang = Storage.get(CURRENT_LANG_STORAGE_KEY);
if (lang) {
setFormData({
lang: {
@ -60,7 +61,6 @@ const Index = () => {
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="emailSend" className="mb-3">
<Form.Label>{t('lang.label')}</Form.Label>
<Form.Select
value={formData.lang.value}
isInvalid={formData.lang.isInvalid}

View File

@ -3,7 +3,7 @@ import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type { FormDataType } from '@answer/common/interface';
import { setNotice, getUserInfo } from '@answer/api';
import { setNotice, getLoggedUserInfo } from '@/services';
import { useToast } from '@answer/hooks';
const Index = () => {
@ -20,7 +20,7 @@ const Index = () => {
});
const getProfile = () => {
getUserInfo().then((res) => {
getLoggedUserInfo().then((res) => {
setFormData({
notice_switch: {
value: res.notice_status === 1,

View File

@ -4,10 +4,10 @@ import { Trans, useTranslation } from 'react-i18next';
import { marked } from 'marked';
import { modifyUserInfo, uploadAvatar, getUserInfo } from '@answer/api';
import { modifyUserInfo, uploadAvatar, getLoggedUserInfo } from '@/services';
import type { FormDataType } from '@answer/common/interface';
import { UploadImg, Avatar } from '@answer/components';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
import { useToast } from '@answer/hooks';
const Index: React.FC = () => {
@ -15,7 +15,7 @@ const Index: React.FC = () => {
keyPrefix: 'settings.profile',
});
const toast = useToast();
const { user, update } = userInfoStore();
const { user, update } = loggedUserInfoStore();
const [formData, setFormData] = useState<FormDataType>({
display_name: {
value: '',
@ -164,7 +164,7 @@ const Index: React.FC = () => {
};
const getProfile = () => {
getUserInfo().then((res) => {
getLoggedUserInfo().then((res) => {
formData.display_name.value = res.display_name;
formData.username.value = res.username;
formData.bio.value = res.bio;

View File

@ -3,7 +3,7 @@ import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { getUserInfo } from '@answer/api';
import { getLoggedUserInfo } from '@/services';
import type { FormDataType } from '@answer/common/interface';
import Nav from './components/Nav';
@ -43,7 +43,7 @@ const Index: React.FC = () => {
},
});
const getProfile = () => {
getUserInfo().then((res) => {
getLoggedUserInfo().then((res) => {
formData.display_name.value = res.display_name;
formData.bio.value = res.bio;
formData.avatar.value = res.avatar;

View File

@ -1,12 +1,12 @@
import { useTranslation } from 'react-i18next';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@answer/stores';
import { PageTitle } from '@/components';
const Suspended = () => {
const { t } = useTranslation('translation', { keyPrefix: 'suspended' });
const userInfo = userInfoStore((state) => state.user);
const userInfo = loggedUserInfoStore((state) => state.user);
if (userInfo.status !== 'forbidden') {
window.location.replace('/');

8
ui/src/router/alias.ts Normal file
View File

@ -0,0 +1,8 @@
export const RouteAlias = {
home: '/',
login: '/users/login',
register: '/users/register',
activation: '/users/login?status=inactive',
activationFailed: '/users/account-activation/failed',
suspended: '/users/account-suspended',
};

42
ui/src/router/guarder.ts Normal file
View File

@ -0,0 +1,42 @@
import {
pullLoggedUser,
isLoggedAndNormal,
isAdminLogged,
isLogged,
isNotLogged,
isNotLoggedOrNormal,
isLoggedAndInactive,
isLoggedAndSuspended,
isNotLoggedOrInactive,
} from '@/utils/guards';
const RouteGuarder = {
base: async () => {
return isNotLoggedOrNormal();
},
logged: async () => {
return isLogged();
},
notLogged: async () => {
return isNotLogged();
},
notLoggedOrInactive: async () => {
return isNotLoggedOrInactive();
},
loggedAndNormal: async () => {
await pullLoggedUser(true);
return isLoggedAndNormal();
},
loggedAndInactive: async () => {
return isLoggedAndInactive();
},
loggedAndSuspended: async () => {
return isLoggedAndSuspended();
},
adminLogged: async () => {
await pullLoggedUser(true);
return isAdminLogged();
},
};
export default RouteGuarder;

View File

@ -1,14 +1,13 @@
import React, { Suspense, lazy } from 'react';
import { RouteObject, createBrowserRouter } from 'react-router-dom';
import { RouteObject, createBrowserRouter, redirect } from 'react-router-dom';
import Layout from '@answer/pages/Layout';
import routeConfig, { RouteNode } from '@/router/route-config';
import RouteRules from '@/router/route-rules';
import Layout from '@/pages/Layout';
import baseRoutes, { RouteNode } from '@/router/routes';
import { floppyNavigation } from '@/utils';
const routes: RouteObject[] = [];
const routeGen = (routeNodes: RouteNode[], root: RouteObject[]) => {
const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
routeNodes.forEach((rn) => {
if (rn.path === '/') {
rn.element = <Layout />;
@ -18,40 +17,37 @@ const routeGen = (routeNodes: RouteNode[], root: RouteObject[]) => {
* ref: https://webpack.js.org/api/module-methods/#import-1
*/
rn.page = rn.page.replace('pages/', '');
const Control = lazy(() => import(`@/pages/${rn.page}`));
const Ctrl = lazy(() => import(`@/pages/${rn.page}`));
rn.element = (
<Suspense>
<Control />
<Ctrl />
</Suspense>
);
}
root.push(rn);
if (Array.isArray(rn.rules)) {
const ruleFunc: Function[] = [];
if (typeof rn.loader === 'function') {
ruleFunc.push(rn.loader);
}
rn.rules.forEach((ruleKey) => {
const func = RouteRules[ruleKey];
if (typeof func === 'function') {
ruleFunc.push(func);
if (rn.guard) {
const { guard } = rn;
const loaderRef = rn.loader;
rn.loader = async (args) => {
const gr = await guard(args);
if (gr?.redirect && floppyNavigation.differentCurrent(gr.redirect)) {
return redirect(gr.redirect);
}
});
rn.loader = ({ params }) => {
ruleFunc.forEach((func) => {
func(params);
});
let ret;
if (typeof loaderRef === 'function') {
ret = await loaderRef(args);
}
return ret;
};
}
const children = Array.isArray(rn.children) ? rn.children : null;
if (children) {
rn.children = [];
routeGen(children, rn.children);
routeWrapper(children, rn.children);
}
});
};
routeGen(routeConfig, routes);
routeWrapper(baseRoutes, routes);
const router = createBrowserRouter(routes);
export default router;
export { routes, createBrowserRouter };

View File

@ -1,9 +0,0 @@
import { isLogin } from '@answer/utils';
const RouteRules = {
isLoginAndNormal: () => {
return isLogin(true);
},
};
export default RouteRules;

View File

@ -1,14 +1,18 @@
import { RouteObject } from 'react-router-dom';
import RouteGuarder from '@/router/guarder';
export interface RouteNode extends RouteObject {
page: string;
children?: RouteNode[];
rules?: string[];
guard?: Function;
}
const routeConfig: RouteNode[] = [
const routes: RouteNode[] = [
{
path: '/',
page: 'pages/Layout',
guard: RouteGuarder.base,
children: [
// question and answer
{
@ -31,12 +35,12 @@ const routeConfig: RouteNode[] = [
{
path: 'questions/ask',
page: 'pages/Questions/Ask',
rules: ['isLoginAndNormal'],
guard: RouteGuarder.loggedAndNormal,
},
{
path: 'posts/:qid/edit',
page: 'pages/Questions/Ask',
rules: ['isLoginAndNormal'],
guard: RouteGuarder.loggedAndNormal,
},
{
path: 'posts/:qid/:aid/edit',
@ -105,18 +109,22 @@ const routeConfig: RouteNode[] = [
{
path: 'users/login',
page: 'pages/Users/Login',
guard: RouteGuarder.notLoggedOrInactive,
},
{
path: 'users/register',
page: 'pages/Users/Register',
guard: RouteGuarder.notLogged,
},
{
path: 'users/account-recovery',
page: 'pages/Users/AccountForgot',
guard: RouteGuarder.loggedAndNormal,
},
{
path: 'users/password-reset',
page: 'pages/Users/PasswordReset',
guard: RouteGuarder.loggedAndNormal,
},
{
path: 'users/account-activation',
@ -142,6 +150,7 @@ const routeConfig: RouteNode[] = [
{
path: 'admin',
page: 'pages/Admin',
guard: RouteGuarder.adminLogged,
children: [
{
index: true,
@ -192,4 +201,4 @@ const routeConfig: RouteNode[] = [
],
},
];
export default routeConfig;
export default routes;

View File

@ -1,6 +1,5 @@
export * from './activity';
export * from './personal';
export * from './user';
export * from './notification';
export * from './question';
export * from './search';

View File

@ -2,9 +2,10 @@ import useSWR from 'swr';
import qs from 'qs';
import request from '@answer/utils/request';
import { isLogin } from '@answer/utils';
import type * as Type from '@answer/common/interface';
import { tryNormalLogged } from '@/utils/guards';
export const useQueryNotifications = (params) => {
const apiUrl = `/answer/api/v1/notification/page?${qs.stringify(params, {
skipNulls: true,
@ -33,7 +34,7 @@ export const useQueryNotificationStatus = () => {
const apiUrl = '/answer/api/v1/notification/status';
return useSWR<{ inbox: number; achievement: number }>(
isLogin() ? apiUrl : null,
tryNormalLogged() ? apiUrl : null,
request.instance.get,
{
refreshInterval: 3000,

View File

@ -1,9 +1,10 @@
import useSWR from 'swr';
import request from '@answer/utils/request';
import { isLogin } from '@answer/utils';
import type * as Type from '@answer/common/interface';
import { tryNormalLogged } from '@/utils/guards';
export const deleteTag = (id) => {
return request.delete('/answer/api/v1/tag', {
tag_id: id,
@ -24,7 +25,7 @@ export const saveSynonymsTags = (params) => {
export const useFollowingTags = () => {
let apiUrl = '';
if (isLogin()) {
if (tryNormalLogged()) {
apiUrl = '/answer/api/v1/tags/following';
}
const { data, error, mutate } = useSWR<any[]>(apiUrl, request.instance.get);

View File

@ -1,17 +0,0 @@
import useSWR from 'swr';
import request from '@answer/utils/request';
export const useCheckUserStatus = () => {
const apiUrl = '/answer/api/v1/user/status';
const hasToken = localStorage.getItem('token');
const { data, error } = useSWR<{ status: string }, Error>(
hasToken ? apiUrl : null,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
};
};

View File

@ -115,7 +115,7 @@ export const resendEmail = (params?: Type.ImgCodeReq) => {
* @description get login userinfo
* @returns {UserInfo}
*/
export const getUserInfo = () => {
export const getLoggedUserInfo = () => {
return request.get<Type.UserInfoRes>('/answer/api/v1/user/info');
};

View File

@ -1,12 +1,12 @@
import toastStore from './toast';
import userInfoStore from './userInfo';
import loggedUserInfoStore from './userInfo';
import globalStore from './global';
import siteInfoStore from './siteInfo';
import interfaceStore from './interface';
export {
toastStore,
userInfoStore,
loggedUserInfoStore,
globalStore,
siteInfoStore,
interfaceStore,

View File

@ -3,6 +3,11 @@ import create from 'zustand';
import type { UserInfoRes } from '@answer/common/interface';
import Storage from '@answer/utils/storage';
import {
LOGGED_USER_STORAGE_KEY,
LOGGED_TOKEN_STORAGE_KEY,
} from '@/common/constants';
interface UserInfoStore {
user: UserInfoRes;
update: (params: UserInfoRes) => void;
@ -19,23 +24,23 @@ const initUser: UserInfoRes = {
location: '',
website: '',
status: '',
mail_status: 0,
mail_status: 1,
};
const userInfoStore = create<UserInfoStore>((set) => ({
const loggedUserInfoStore = create<UserInfoStore>((set) => ({
user: initUser,
update: (params) =>
set(() => {
Storage.set('token', params.access_token);
Storage.set('userInfo', params);
Storage.set(LOGGED_TOKEN_STORAGE_KEY, params.access_token);
Storage.set(LOGGED_USER_STORAGE_KEY, params);
return { user: params };
}),
clear: () =>
set(() => {
// Storage.remove('token');
Storage.remove('userInfo');
Storage.remove(LOGGED_TOKEN_STORAGE_KEY);
Storage.remove(LOGGED_USER_STORAGE_KEY);
return { user: initUser };
}),
}));
export default userInfoStore;
export default loggedUserInfoStore;

80
ui/src/utils/common.ts Normal file
View File

@ -0,0 +1,80 @@
function getQueryString(name: string): string {
const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`);
const r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
return '';
}
function thousandthDivision(num) {
const reg = /\d{1,3}(?=(\d{3})+$)/g;
return `${num}`.replace(reg, '$&,');
}
function formatCount($num: number): string {
let res = String($num);
if (!Number.isFinite($num)) {
res = '0';
} else if ($num < 10000) {
res = thousandthDivision($num);
} else if ($num < 1000000) {
res = `${Math.round($num / 100) / 10}k`;
} else if ($num >= 1000000) {
res = `${Math.round($num / 100000) / 10}m`;
}
return res;
}
function scrollTop(element) {
if (!element) {
return;
}
const offset = 120;
const bodyRect = document.body.getBoundingClientRect().top;
const elementRect = element.getBoundingClientRect().top;
const elementPosition = elementRect - bodyRect;
const offsetPosition = elementPosition - offset;
window.scrollTo({
top: offsetPosition,
});
}
/**
* Extract user info from markdown
* @param markdown string
* @returns Array<{displayName: string, userName: string}>
*/
function matchedUsers(markdown) {
const globalReg = /\B@([\w|]+)/g;
const reg = /\B@([\w\\_\\.]+)/;
const users = markdown.match(globalReg);
if (!users) {
return [];
}
return users.map((user) => {
const matched = user.match(reg);
return {
userName: matched[1],
};
});
}
/**
* Identify user information from markdown
* @param markdown string
* @returns string
*/
function parseUserInfo(markdown) {
const globalReg = /\B@([\w\\_\\.\\-]+)/g;
return markdown.replace(globalReg, '[@$1](/u/$1)');
}
export {
getQueryString,
thousandthDivision,
formatCount,
scrollTop,
matchedUsers,
parseUserInfo,
};

View File

@ -0,0 +1,40 @@
import { RouteAlias } from '@/router/alias';
import Storage from '@/utils/storage';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
const differentCurrent = (target: string, base?: string) => {
base ||= window.location.origin;
const targetUrl = new URL(target, base);
return targetUrl.toString() !== window.location.href;
};
/**
* only navigate if not same as current url
* @param pathname
* @param callback
*/
const navigate = (pathname: string, callback: Function) => {
if (differentCurrent(pathname)) {
callback();
}
};
/**
* auto navigate to login page with redirect info
*/
const navigateToLogin = () => {
const { pathname } = window.location;
if (pathname !== RouteAlias.login && pathname !== RouteAlias.register) {
const redirectUrl = window.location.href;
Storage.set(REDIRECT_PATH_STORAGE_KEY, redirectUrl);
}
navigate(RouteAlias.login, () => {
window.location.replace(RouteAlias.login);
});
};
export const floppyNavigation = {
differentCurrent,
navigate,
navigateToLogin,
};

182
ui/src/utils/guards.ts Normal file
View File

@ -0,0 +1,182 @@
import { getLoggedUserInfo } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import { RouteAlias } from '@/router/alias';
import Storage from '@/utils/storage';
import { LOGGED_USER_STORAGE_KEY } from '@/common/constants';
import { floppyNavigation } from '@/utils/floppyNavigation';
type UserStat = {
isLogged: boolean;
isActivated: boolean;
isSuspended: boolean;
isNormal: boolean;
isAdmin: boolean;
};
export const deriveUserStat = (): UserStat => {
const stat: UserStat = {
isLogged: false,
isActivated: false,
isSuspended: false,
isNormal: false,
isAdmin: false,
};
const { user } = loggedUserInfoStore.getState();
if (user.id && user.username) {
stat.isLogged = true;
}
if (stat.isLogged && user.mail_status === 1) {
stat.isActivated = true;
}
if (stat.isLogged && user.status === 'forbidden') {
stat.isSuspended = true;
}
if (stat.isLogged && stat.isActivated && !stat.isSuspended) {
stat.isNormal = true;
}
if (stat.isNormal && user.is_admin === true) {
stat.isAdmin = true;
}
return stat;
};
type GuardResult = {
ok: boolean;
redirect?: string;
};
let pullLock = false;
let dedupeTimestamp = 0;
export const pullLoggedUser = async (forceRePull = false) => {
// only pull once if not force re-pull
if (pullLock && !forceRePull) {
return;
}
// dedupe pull requests in this time span in 10 seconds
if (Date.now() - dedupeTimestamp < 1000 * 10) {
return;
}
dedupeTimestamp = Date.now();
const loggedUserInfo = await getLoggedUserInfo().catch((ex) => {
dedupeTimestamp = 0;
if (!deriveUserStat().isLogged) {
// load fallback userInfo from local storage
const storageLoggedUserInfo = Storage.get(LOGGED_USER_STORAGE_KEY);
if (storageLoggedUserInfo) {
loggedUserInfoStore.getState().update(storageLoggedUserInfo);
}
}
console.error(ex);
});
if (loggedUserInfo) {
pullLock = true;
loggedUserInfoStore.getState().update(loggedUserInfo);
}
};
export const isLogged = () => {
const ret: GuardResult = { ok: true, redirect: undefined };
const userStat = deriveUserStat();
if (!userStat.isLogged) {
ret.ok = false;
ret.redirect = RouteAlias.login;
}
return ret;
};
export const isNotLogged = () => {
const ret: GuardResult = { ok: true, redirect: undefined };
const userStat = deriveUserStat();
if (userStat.isLogged) {
ret.ok = false;
ret.redirect = RouteAlias.home;
}
return ret;
};
export const isLoggedAndInactive = () => {
const ret: GuardResult = { ok: false, redirect: undefined };
const userStat = deriveUserStat();
if (!userStat.isActivated) {
ret.ok = true;
ret.redirect = RouteAlias.activation;
}
return ret;
};
export const isLoggedAndSuspended = () => {
const ret: GuardResult = { ok: false, redirect: undefined };
const userStat = deriveUserStat();
if (userStat.isSuspended) {
ret.redirect = RouteAlias.suspended;
ret.ok = true;
}
return ret;
};
export const isLoggedAndNormal = () => {
const ret: GuardResult = { ok: false, redirect: undefined };
const userStat = deriveUserStat();
if (userStat.isNormal) {
ret.ok = true;
} else if (!userStat.isActivated) {
ret.redirect = RouteAlias.activation;
} else if (!userStat.isSuspended) {
ret.redirect = RouteAlias.suspended;
} else if (!userStat.isLogged) {
ret.redirect = RouteAlias.login;
}
return ret;
};
export const isNotLoggedOrNormal = () => {
const ret: GuardResult = { ok: true, redirect: undefined };
const userStat = deriveUserStat();
const gr = isLoggedAndNormal();
if (!gr.ok && userStat.isLogged) {
ret.ok = false;
ret.redirect = gr.redirect;
}
return ret;
};
export const isNotLoggedOrInactive = () => {
const ret: GuardResult = { ok: true, redirect: undefined };
const userStat = deriveUserStat();
if (userStat.isLogged || userStat.isActivated) {
ret.ok = false;
ret.redirect = RouteAlias.home;
}
return ret;
};
export const isAdminLogged = () => {
const ret: GuardResult = { ok: true, redirect: undefined };
const userStat = deriveUserStat();
if (!userStat.isAdmin) {
ret.redirect = RouteAlias.home;
ret.ok = false;
}
return ret;
};
/**
* try user was logged and all state ok
* @param autoLogin
*/
export const tryNormalLogged = (autoLogin: boolean = false) => {
const gr = isLoggedAndNormal();
if (gr.ok) {
return true;
}
if (gr.redirect === RouteAlias.login && autoLogin) {
floppyNavigation.navigateToLogin();
} else if (gr.redirect) {
floppyNavigation.navigate(gr.redirect, () => {
// @ts-ignore
window.location.replace(gr.redirect);
});
}
return false;
};

View File

@ -1,114 +1,6 @@
import { LOGIN_NEED_BACK } from '@answer/common/constants';
export * from './common';
export * as guards from './guards';
import Storage from './storage';
function getQueryString(name: string): string {
const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`);
const r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
return '';
}
function thousandthDivision(num) {
const reg = /\d{1,3}(?=(\d{3})+$)/g;
return `${num}`.replace(reg, '$&,');
}
function formatCount($num: number): string {
let res = String($num);
if (!Number.isFinite($num)) {
res = '0';
} else if ($num < 10000) {
res = thousandthDivision($num);
} else if ($num < 1000000) {
res = `${Math.round($num / 100) / 10}k`;
} else if ($num >= 1000000) {
res = `${Math.round($num / 100000) / 10}m`;
}
return res;
}
function isLogin(needToLogin?: boolean): boolean {
const user = Storage.get('userInfo');
const path = window.location.pathname;
// User deleted or suspended
if (user.username && user.status === 'forbidden') {
if (path !== '/users/account-suspended') {
window.location.pathname = '/users/account-suspended';
}
return false;
}
// login and active
if (user.username && user.mail_status === 1) {
if (LOGIN_NEED_BACK.includes(path)) {
window.location.replace('/');
}
return true;
}
// un login or inactivated
if ((!user.username || user.mail_status === 2) && needToLogin) {
Storage.set('ANSWER_PATH', path);
window.location.href = '/users/login';
}
return false;
}
function scrollTop(element) {
if (!element) {
return;
}
const offset = 120;
const bodyRect = document.body.getBoundingClientRect().top;
const elementRect = element.getBoundingClientRect().top;
const elementPosition = elementRect - bodyRect;
const offsetPosition = elementPosition - offset;
window.scrollTo({
top: offsetPosition,
});
}
/**
* Extract user info from markdown
* @param markdown string
* @returns Array<{displayName: string, userName: string}>
*/
function matchedUsers(markdown) {
const globalReg = /\B@([\w|]+)/g;
const reg = /\B@([\w\\_\\.]+)/;
const users = markdown.match(globalReg);
if (!users) {
return [];
}
return users.map((user) => {
const matched = user.match(reg);
return {
userName: matched[1],
};
});
}
/**
* Identify user infromation from markdown
* @param markdown string
* @returns string
*/
function parseUserInfo(markdown) {
const globalReg = /\B@([\w\\_\\.\\-]+)/g;
return markdown.replace(globalReg, '[@$1](/u/$1)');
}
export {
getQueryString,
thousandthDivision,
formatCount,
isLogin,
scrollTop,
matchedUsers,
parseUserInfo,
};
export { default as request } from './request';
export { default as Storage } from './storage';
export { floppyNavigation } from './floppyNavigation';

View File

@ -2,9 +2,17 @@ import axios, { AxiosResponse } from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { Modal } from '@answer/components';
import { userInfoStore, toastStore } from '@answer/stores';
import { loggedUserInfoStore, toastStore } from '@answer/stores';
import Storage from './storage';
import { floppyNavigation } from './floppyNavigation';
import {
LOGGED_TOKEN_STORAGE_KEY,
CURRENT_LANG_STORAGE_KEY,
DEFAULT_LANG,
} from '@/common/constants';
import { RouteAlias } from '@/router/alias';
const API = {
development: '',
@ -25,12 +33,11 @@ class Request {
constructor(config: AxiosRequestConfig) {
this.instance = axios.create(config);
this.instance.interceptors.request.use(
(requestConfig: AxiosRequestConfig) => {
const token = Storage.get('token') || '';
const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || '';
// default lang en_US
const lang = Storage.get('LANG') || 'en_US';
const lang = Storage.get(CURRENT_LANG_STORAGE_KEY) || DEFAULT_LANG;
requestConfig.headers = {
Authorization: token,
'Accept-Language': lang,
@ -54,23 +61,23 @@ class Request {
return data;
},
(error) => {
const { status, data, msg } = error.response;
const { data: realData, msg: realMsg = '' } = data;
const { status, data: respData, msg: respMsg } = error.response;
const { data, msg = '' } = respData;
if (status === 400) {
// show error message
if (realData instanceof Object && realData.err_type) {
if (realData.err_type === 'toast') {
if (data instanceof Object && data.err_type) {
if (data.err_type === 'toast') {
// toast error message
toastStore.getState().show({
msg: realMsg,
msg,
variant: 'danger',
});
}
if (realData.type === 'modal') {
if (data.type === 'modal') {
// modal error message
Modal.confirm({
content: realMsg,
content: msg,
});
}
@ -78,63 +85,56 @@ class Request {
}
if (
realData instanceof Object &&
Object.keys(realData).length > 0 &&
realData.key
data instanceof Object &&
Object.keys(data).length > 0 &&
data.key
) {
// handle form error
return Promise.reject({ ...realData, isError: true });
return Promise.reject({ ...data, isError: true });
}
if (!realData || Object.keys(realData).length <= 0) {
if (!data || Object.keys(data).length <= 0) {
// default error msg will show modal
Modal.confirm({
content: realMsg,
content: msg,
});
return Promise.reject(false);
}
}
// 401: Re-login required
if (status === 401) {
// clear userinfo
loggedUserInfoStore.getState().clear();
floppyNavigation.navigateToLogin();
return Promise.reject(false);
}
if (status === 403) {
// Permission interception
if (data?.type === 'url_expired') {
// url expired
floppyNavigation.navigate(RouteAlias.activationFailed, () => {
window.location.replace(RouteAlias.activationFailed);
});
return Promise.reject(false);
}
if (data?.type === 'inactive') {
// inactivated
floppyNavigation.navigate(RouteAlias.activation, () => {
window.location.href = RouteAlias.activation;
});
return Promise.reject(false);
}
if (data?.type === 'suspended') {
floppyNavigation.navigate(RouteAlias.suspended, () => {
window.location.replace(RouteAlias.suspended);
});
return Promise.reject(false);
}
}
if (status === 401) {
// clear userinfo;
Storage.remove('token');
userInfoStore.getState().clear();
// need login
const { pathname } = window.location;
if (pathname !== '/users/login' && pathname !== '/users/register') {
Storage.set('ANSWER_PATH', window.location.pathname);
}
window.location.href = '/users/login';
return Promise.reject(false);
}
if (status === 403) {
// Permission interception
if (realData?.type === 'inactive') {
// inactivated
window.location.href = '/users/login?status=inactive';
return Promise.reject(false);
}
if (realData?.type === 'url_expired') {
// url expired
window.location.href = '/users/account-activation/failed';
return Promise.reject(false);
}
if (realData?.type === 'suspended') {
if (window.location.pathname !== '/users/account-suspended') {
window.location.href = '/users/account-suspended';
}
return Promise.reject(false);
}
}
toastStore.getState().show({
msg: `statusCode: ${status}; ${msg || ''}`,
msg: `statusCode: ${status}; ${respMsg || ''}`,
variant: 'danger',
});
return Promise.reject(false);
@ -178,6 +178,4 @@ class Request {
}
}
// export const Request;
export default new Request(baseConfig);

View File

@ -3,13 +3,12 @@ const Storage = {
const value = localStorage.getItem(key);
if (value) {
try {
const v = JSON.parse(value);
return v;
return JSON.parse(value);
} catch {
return value;
}
}
return false;
return undefined;
},
set: (key: string, value: any): void => {
if (typeof value === 'string') {

View File

@ -26,7 +26,6 @@
"@answer/components/*": ["src/components/*"],
"@answer/stores": ["src/stores"],
"@answer/stores/*": ["src/stores/*"],
"@answer/api": ["src/services/api.ts"],
"@answer/services/*": ["src/services/*"],
"@answer/hooks": ["src/hooks"],
"@answer/common": ["src/common"],